diff --git a/.github/actions/scripts/run-utp-tests.sh b/.github/actions/scripts/run-utp-tests.sh index 892cfdf..c9e1d02 100755 --- a/.github/actions/scripts/run-utp-tests.sh +++ b/.github/actions/scripts/run-utp-tests.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -set -uo pipefail +set -euo pipefail _UTP_HELPERS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utp-ci-assertion-helpers.sh" # shellcheck source=utp-ci-assertion-helpers.sh @@ -23,28 +23,26 @@ fi IFS=',' read -ra tests <<< "$TESTS_INPUT" failures=0 +scenarios_run=0 -declare -A known_tests=( - [CompilerWarnings]=1 - [CompilerErrors]=1 - [BuildWarnings]=1 - [BuildErrors]=1 - [PlaymodeTestsErrors]=1 - [EditmodeTestsErrors]=1 - [EditmodeTestsPassing]=1 - [EditmodeTestsSkipped]=1 - [PlaymodeTestsPassing]=1 - [PlaymodeTestsSkipped]=1 - [EditmodeSuite]=1 - [PlaymodeSuite]=1 -) +# Bash 3.2-safe (macOS /bin/bash): avoid declare -A with unquoted keys under set -u. +is_known_utp_test() { + case "$1" in + CompilerWarnings|CompilerErrors|BuildWarnings|BuildErrors|PlaymodeTestsErrors|EditmodeTestsErrors|EditmodeTestsPassing|EditmodeTestsSkipped|PlaymodeTestsPassing|PlaymodeTestsSkipped|EditmodeSuite|PlaymodeSuite) + return 0 + ;; + *) + return 1 + ;; + esac +} effective_tests=0 declare -a selected_tests=() for raw_test in "${tests[@]}"; do tname="$(echo "$raw_test" | xargs)" if [ -n "$tname" ] && [ "$tname" != "None" ]; then - if [ -z "${known_tests[$tname]+x}" ]; then + if ! is_known_utp_test "$tname"; then echo "::error::TESTS_INPUT includes unknown test selection '$tname'" exit 1 fi @@ -110,6 +108,8 @@ for raw_test in "${tests[@]}"; do continue fi + scenarios_run=$((scenarios_run + 1)) + src="$GITHUB_WORKSPACE/unity-tests/${test_name}.cs" is_suite=0 case "$test_name" in @@ -386,6 +386,23 @@ for raw_test in "${tests[@]}"; do done +if [ "$scenarios_run" -eq 0 ]; then + echo "::error::No UTP scenarios were executed (harness aborted or empty selection)" + exit 1 +fi + +utp_log_count=0 +if [ -d "$UNITY_PROJECT_PATH/Builds/Logs" ]; then + utp_log_count=$(find "$UNITY_PROJECT_PATH/Builds/Logs" -maxdepth 1 -type f -name '*-utp-json.log' 2>/dev/null | wc -l | tr -d ' ') +fi +if [ "${utp_log_count:-0}" -eq 0 ] && [ -d "${GITHUB_WORKSPACE:-}/utp-artifacts" ]; then + utp_log_count=$(find "$GITHUB_WORKSPACE/utp-artifacts" -type f -name '*-utp-json.log' 2>/dev/null | wc -l | tr -d ' ') +fi +if [ "${utp_log_count:-0}" -eq 0 ]; then + echo "::error::No *-utp-json.log artifacts were produced after ${scenarios_run} scenario(s) — treating as harness failure" + exit 1 +fi + if [ "$failures" -gt 0 ]; then echo "::error::One or more tests did not meet expectations ($failures)" exit 1 diff --git a/.github/actions/scripts/utp-ci-assertion-helpers.sh b/.github/actions/scripts/utp-ci-assertion-helpers.sh index 004c34f..888faff 100644 --- a/.github/actions/scripts/utp-ci-assertion-helpers.sh +++ b/.github/actions/scripts/utp-ci-assertion-helpers.sh @@ -1,6 +1,20 @@ #!/usr/bin/env bash # Shared helpers for UTP CI batch validation (.github/actions/scripts/run-utp-tests.sh). # Keep behavior in sync with contract tests: tests/run-utp-tests-contract.sh +# +# Severity checks go through utp-file-has-actionable-severity.cjs → normalizeTelemetryEntry +# so benign Unity noise (multicast WSAEACCES, OpenCL, StackAllocator, etc.) is remapped +# before Error/Exception/Assert are treated as failures. Do not re-special-case messages here. + +_UTP_ASSERT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +_UTP_ACTIONABLE_SEVERITY_JS="${_UTP_ASSERT_DIR}/utp-file-has-actionable-severity.cjs" + +# Returns 0 if the UTP file has an actionable Error/Exception/(optional Assert) after normalize. +utp_file_has_actionable_severity() { + local utp_file="$1" + local severities_re="$2" + node "$_UTP_ACTIONABLE_SEVERITY_JS" "$utp_file" "$severities_re" +} # Returns 0 (true) if this UTP JSON log should fail an *expected-success* scenario. utp_signals_failure_for_expected_success() { @@ -9,18 +23,18 @@ utp_signals_failure_for_expected_success() { case "$test_name" in CompilerWarnings|BuildWarnings) # Engine / allocator assert telemetry is common here; only treat Error/Exception as hard failures. - grep -qi '"severity"[[:space:]]*:[[:space:]]*"\(Error\|Exception\)"' "$utp_file" 2>/dev/null + utp_file_has_actionable_severity "$utp_file" 'Error|Exception' ;; *) - grep -qi '"severity"[[:space:]]*:[[:space:]]*"\(Error\|Exception\|Assert\)"' "$utp_file" 2>/dev/null + utp_file_has_actionable_severity "$utp_file" 'Error|Exception|Assert' ;; esac } -# Returns 0 if UTP log contains any Error/Exception/Assert (used for expected-failure scenarios). +# Returns 0 if UTP log contains any actionable Error/Exception/Assert (expected-failure scenarios). utp_signals_any_severity_problem() { local utp_file="$1" - grep -qi '"severity"[[:space:]]*:[[:space:]]*"\(Error\|Exception\|Assert\)"' "$utp_file" 2>/dev/null + utp_file_has_actionable_severity "$utp_file" 'Error|Exception|Assert' } # Prints first path to an NUnit results file containing , or nothing. diff --git a/.github/actions/scripts/utp-file-has-actionable-severity.cjs b/.github/actions/scripts/utp-file-has-actionable-severity.cjs new file mode 100644 index 0000000..65cda11 --- /dev/null +++ b/.github/actions/scripts/utp-file-has-actionable-severity.cjs @@ -0,0 +1,69 @@ +/** + * CI helper: parse a *-utp-json.log (pretty JSON array from unity-cli, or NDJSON fixtures) + * through normalizeTelemetryEntry so benign elevated severities are remapped, then exit 0 + * if any remaining severity is in the requested set. + * + * Usage: node utp-file-has-actionable-severity.cjs [Error|Exception|Assert] + * Exit 0 = has actionable severity; exit 1 = none; exit 2 = usage/IO/parse error. + * + * Requires `npm run build` so dist/utp.js exists (same as scan-utp-artifacts.cjs). + */ +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '..', '..', '..'); +const distUtp = path.join(repoRoot, 'dist', 'utp.js'); +if (!fs.existsSync(distUtp)) { + console.error(`utp-file-has-actionable-severity: missing ${distUtp} (run npm run build)`); + process.exit(2); +} + +const { normalizeTelemetryEntry } = require(distUtp); + +const filePath = process.argv[2]; +const severitiesArg = process.argv[3] || 'Error|Exception|Assert'; +if (!filePath) { + console.error('Usage: node utp-file-has-actionable-severity.cjs [Error|Exception|Assert]'); + process.exit(2); +} + +const severitySet = new Set(severitiesArg.split('|').map(s => s.trim()).filter(Boolean)); + +function loadEntries(raw) { + const trimmed = raw.trim(); + if (!trimmed) { + return []; + } + try { + const data = JSON.parse(trimmed); + return Array.isArray(data) ? data : [data]; + } catch { + // NDJSON / one compact object per line (contract fixtures) + const out = []; + for (const line of trimmed.split(/\r?\n/)) { + const t = line.trim(); + if (!t) { + continue; + } + out.push(JSON.parse(t)); + } + return out; + } +} + +let entries; +try { + entries = loadEntries(fs.readFileSync(filePath, 'utf8')); +} catch (err) { + console.error(`utp-file-has-actionable-severity: ${err.message || err}`); + process.exit(2); +} + +for (const entry of entries) { + const { utp } = normalizeTelemetryEntry(entry); + if (utp && utp.severity && severitySet.has(String(utp.severity))) { + process.exit(0); + } +} + +process.exit(1); diff --git a/.github/scripts/unity-release-discovery.cjs b/.github/scripts/unity-release-discovery.cjs new file mode 100644 index 0000000..6c5d87e --- /dev/null +++ b/.github/scripts/unity-release-discovery.cjs @@ -0,0 +1,234 @@ +/** + * Weekly Unity release discovery: compare Releases API tips to CI matrix + canary pin. + * Usage: node .github/scripts/unity-release-discovery.cjs + * Env: GITHUB_STEP_SUMMARY (optional), OPEN_ISSUE=1 + GH_TOKEN/GITHUB_TOKEN to open/update drift issue. + */ +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const repoRoot = path.join(__dirname, '..', '..'); +const apiBase = 'https://services.api.unity.com/unity/editor/release/v1/releases'; +const minors = ['6000.0', '6000.1', '6000.2', '6000.3', '6000.4', '6000.5', '6000.6', '6000.7']; +const ISSUE_TITLE = 'unity-release-drift: matrix / canary vs Unity Releases API'; +const ISSUE_LABEL = 'unity-release-drift'; + +async function fetchLatest(versionPrefix) { + const url = `${apiBase}?version=${encodeURIComponent(versionPrefix)}&limit=10`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Releases API ${res.status} for ${versionPrefix}`); + } + const data = await res.json(); + const results = Array.isArray(data.results) ? data.results : []; + return results.map(r => ({ + version: r.version, + stream: r.stream, + shortRevision: r.shortRevision, + releaseDate: r.releaseDate, + })); +} + +function loadMatrixVersions() { + const buildOptions = JSON.parse( + fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'build-options.json'), 'utf8') + ); + return buildOptions['unity-version'] || []; +} + +function loadCanaryPin() { + const yml = fs.readFileSync( + path.join(repoRoot, '.github', 'workflows', 'unity-preview-canary.yml'), + 'utf8' + ); + const version = (yml.match(/"unity-version":\s*"([^"]+)"/) || [])[1]; + const changeset = (yml.match(/"changeset":\s*"([^"]+)"/) || [])[1]; + const channel = (yml.match(/"channel":\s*"([^"]+)"/) || [])[1]; + return { version, changeset, channel }; +} + +function pickTip(rows, preferStreams) { + for (const stream of preferStreams) { + const hit = rows.find(r => String(r.stream).toUpperCase() === stream); + if (hit) return hit; + } + return rows[0]; +} + +async function main() { + const matrixVersions = loadMatrixVersions(); + const canary = loadCanaryPin(); + const tips = []; + + for (const minor of minors) { + try { + const rows = await fetchLatest(minor); + const tip = pickTip(rows, ['LTS', 'SUPPORTED', 'BETA', 'ALPHA']); + tips.push({ minor, tip, count: rows.length }); + } catch (err) { + tips.push({ minor, tip: null, error: err.message }); + } + } + + const lines = []; + lines.push('## Unity release discovery'); + lines.push(''); + lines.push('### Blocking matrix (`build-options.json`)'); + lines.push(''); + for (const v of matrixVersions) { + lines.push(`- \`${v}\``); + } + lines.push(''); + lines.push('### Preview canary pin'); + lines.push(''); + lines.push(`- version: \`${canary.version || 'n/a'}\``); + lines.push(`- changeset: \`${canary.changeset || 'n/a'}\``); + lines.push(`- channel: \`${canary.channel || 'n/a'}\``); + lines.push(''); + lines.push('### API tips by minor'); + lines.push(''); + lines.push('| Minor | Tip version | Stream | Changeset |'); + lines.push('|-------|-------------|--------|-----------|'); + for (const row of tips) { + if (!row.tip) { + lines.push(`| ${row.minor} | _(error)_ | | ${row.error || ''} |`); + continue; + } + lines.push( + `| ${row.minor} | ${row.tip.version} | ${row.tip.stream} | ${row.tip.shortRevision} |` + ); + } + lines.push(''); + + const drifts = []; + const tip60006 = tips.find(t => t.minor === '6000.6')?.tip; + if (tip60006 && canary.version && tip60006.version !== canary.version) { + drifts.push( + `Canary pin ${canary.version} is behind API tip ${tip60006.version} (${tip60006.stream}, ${tip60006.shortRevision}).` + ); + } + for (const row of tips) { + if (!row.tip) continue; + const stream = String(row.tip.stream || '').toUpperCase(); + if (stream !== 'LTS' && stream !== 'SUPPORTED') continue; + const minorKey = row.minor; // e.g. 6000.5 + const covered = matrixVersions.some(v => { + if (v === minorKey) return true; + if (v.startsWith(minorKey + '.')) return true; + if (v === `${minorKey}.x` || v === `${minorKey}.*`) return true; + // 6000.0.x style + const m = /^(\d+\.\d+)/.exec(v); + return m && m[1] === minorKey; + }); + if (!covered && !matrixVersions.includes(row.tip.version)) { + drifts.push( + `New ${stream} tip ${row.tip.version} for ${minorKey} is not represented in blocking matrix.` + ); + } + } + + if (drifts.length === 0) { + lines.push('### Drift'); + lines.push(''); + lines.push('No matrix/canary drift detected against API tips.'); + } else { + lines.push('### Drift'); + lines.push(''); + for (const d of drifts) { + lines.push(`- ${d}`); + } + } + + const body = lines.join('\n'); + console.log(body); + + // Step summary: local matrix/canary + drift count only (no HTTP-tainted API tip fields). + if (process.env.GITHUB_STEP_SUMMARY) { + const summaryPath = path.resolve(process.env.GITHUB_STEP_SUMMARY); + const runnerTemp = process.env.RUNNER_TEMP ? path.resolve(process.env.RUNNER_TEMP) : ''; + const underTemp = + runnerTemp && + (summaryPath === runnerTemp || summaryPath.startsWith(runnerTemp + path.sep)); + if (underTemp || process.env.GITHUB_ACTIONS === 'true') { + const localSummary = [ + '## Unity release discovery', + '', + '### Blocking matrix (`build-options.json`)', + '', + ...matrixVersions.map(v => `- \`${v}\``), + '', + '### Preview canary pin', + '', + `- version: \`${canary.version || 'n/a'}\``, + `- changeset: \`${canary.changeset || 'n/a'}\``, + `- channel: \`${canary.channel || 'n/a'}\``, + '', + '### Drift', + '', + drifts.length === 0 + ? 'No matrix/canary drift detected against API tips.' + : `${drifts.length} drift item(s) detected. See the job log for API tip details.`, + '', + ].join('\n'); + fs.appendFileSync(summaryPath, localSummary); + } + } + + if (process.env.OPEN_ISSUE === '1' && drifts.length > 0) { + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (!token) { + console.error('OPEN_ISSUE=1 but no GH_TOKEN/GITHUB_TOKEN'); + process.exitCode = 1; + return; + } + try { + const existing = execFileSync( + 'gh', + ['issue', 'list', '--label', ISSUE_LABEL, '--state', 'open', '--json', 'number,title', '--limit', '5'], + { encoding: 'utf8' } + ); + const issues = JSON.parse(existing || '[]'); + const hit = issues.find(i => i.title === ISSUE_TITLE); + const issueBody = [ + 'Automated drift report from `unity-release-discovery`.', + '', + ...drifts.map(d => `- ${d}`), + '', + body, + ].join('\n'); + try { + execFileSync('gh', ['label', 'create', ISSUE_LABEL, '--force'], { stdio: 'ignore' }); + } catch { + // label may already exist or lack permission; issue create can omit it + } + if (hit) { + execFileSync('gh', ['issue', 'comment', String(hit.number), '--body', issueBody], { + stdio: 'inherit', + }); + console.log(`Updated issue #${hit.number}`); + } else { + try { + execFileSync( + 'gh', + ['issue', 'create', '--title', ISSUE_TITLE, '--label', ISSUE_LABEL, '--body', issueBody], + { stdio: 'inherit' } + ); + } catch { + execFileSync( + 'gh', + ['issue', 'create', '--title', ISSUE_TITLE, '--body', issueBody], + { stdio: 'inherit' } + ); + } + } + } catch (err) { + console.error(`Issue upsert failed: ${err.message || err}`); + process.exitCode = 1; + } + } +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/.github/workflows/build-options.json b/.github/workflows/build-options.json index 3d4812c..9178fb3 100644 --- a/.github/workflows/build-options.json +++ b/.github/workflows/build-options.json @@ -18,8 +18,7 @@ "6000.2", "6000.3", "6000.4", - "6000.5", - "6000.6" + "6000.5" ], "include": [ { diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 776b545..469d69b 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -17,6 +17,12 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + - run: npm ci + - run: npm run build - name: UTP batch assertion helpers (contract) run: bash tests/run-utp-tests-contract.sh setup: diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index f85dda1..1f5192b 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -66,13 +66,30 @@ jobs: exit 1 fi if [ "${{ matrix.unity-version }}" != "none" ]; then - unity-cli setup-unity --unity-version "${{ matrix.unity-version }}" --build-targets "${{ matrix.build-target }}" --json + setup_cmd=( + unity-cli setup-unity + --unity-version "${{ matrix.unity-version }}" + --build-targets "${{ matrix.build-target }}" + --json + ) + CHANNEL="${{ matrix.channel }}" + CHANGESET="${{ matrix.changeset }}" + if [ -n "${CHANNEL}" ]; then + setup_cmd+=(--channel "${CHANNEL}") + fi + if [ -n "${CHANGESET}" ]; then + setup_cmd+=(--changeset "${CHANGESET}") + fi + "${setup_cmd[@]}" fi - name: Verify UNITY_HUB_PATH and UNITY_EDITOR_PATH variables run: | echo "UNITY_HUB_PATH: ${UNITY_HUB_PATH}" echo "UNITY_EDITOR_PATH: ${UNITY_EDITOR_PATH}" echo "UNITY_PROJECT_PATH: ${UNITY_PROJECT_PATH}" # Expected to be empty at this point + echo "UNITY_EDITOR_VERSION: ${UNITY_EDITOR_VERSION:-}" + echo "UNITY_EDITOR_CHANGESET: ${UNITY_EDITOR_CHANGESET:-}" + echo "UNITY_EDITOR_CHANNELS: ${UNITY_EDITOR_CHANNELS:-}" if [ -z "${UNITY_HUB_PATH}" ]; then echo "::error::UNITY_HUB_PATH is not set" @@ -83,6 +100,17 @@ jobs: echo "::error::UNITY_EDITOR_PATH is not set" exit 1 fi + + if [ "${{ matrix.unity-version }}" != "none" ]; then + { + echo "### Resolved Unity editor" + echo "" + echo "- version: \`${UNITY_EDITOR_VERSION:-n/a}\`" + echo "- changeset: \`${UNITY_EDITOR_CHANGESET:-n/a}\`" + echo "- channels: \`${UNITY_EDITOR_CHANNELS:-f}\`" + echo "- path: \`${UNITY_EDITOR_PATH:-n/a}\`" + } >> "$GITHUB_STEP_SUMMARY" + fi - name: Activate License if: ${{ matrix.unity-version != 'none' }} run: | @@ -183,7 +211,7 @@ jobs: run: | set -euo pipefail # Keep this alternation in sync with hard failures from .github/actions/scripts/run-utp-tests.sh - failure_markers='One or more tests did not meet expectations|was expected to succeed but failed|produced UTP errors but was expected to succeed' + failure_markers='One or more tests did not meet expectations|was expected to succeed but failed|produced UTP errors but was expected to succeed|No \*-utp-json.log artifacts were produced|No UTP scenarios were executed' log_dir="${UNITY_PROJECT_PATH}/Builds/Logs" artifacts_dir="${GITHUB_WORKSPACE}/utp-artifacts" @@ -212,6 +240,18 @@ jobs: fi fi + utp_count=0 + if [ -d "$log_dir" ]; then + utp_count=$(find "$log_dir" -maxdepth 1 -type f -name '*-utp-json.log' 2>/dev/null | wc -l | tr -d ' ') + fi + if [ "${utp_count:-0}" -eq 0 ] && [ -d "$artifacts_dir" ]; then + utp_count=$(find "$artifacts_dir" -type f -name '*-utp-json.log' 2>/dev/null | wc -l | tr -d ' ') + fi + if [ "${utp_count:-0}" -eq 0 ]; then + echo "::error::RUN_BUILD=true but no *-utp-json.log found under Logs or utp-artifacts (possible false-green / harness abort)" + exit 1 + fi + if [ "$marker_found" -ne 0 ]; then exit 1 fi diff --git a/.github/workflows/unity-preview-canary.yml b/.github/workflows/unity-preview-canary.yml new file mode 100644 index 0000000..172de1a --- /dev/null +++ b/.github/workflows/unity-preview-canary.yml @@ -0,0 +1,44 @@ +# Non-blocking preview canary: newest Unity beta line (6000.6) with explicit channel + changeset. +# Does not gate PR merges. TMP/buildpipeline issues may keep this red until upstream fix ships. +name: unity-preview-canary +on: + schedule: + - cron: '0 6 * * 1' # weekly Monday 06:00 UTC + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + canary: + name: preview canary + uses: ./.github/workflows/unity-build.yml + continue-on-error: true + permissions: + contents: read + checks: write + secrets: + UNITY_USERNAME: ${{ secrets.UNITY_USERNAME }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + with: + utp-test-profile: normal + matrix: >- + { + "include": [ + { + "name": "ubuntu-latest StandaloneLinux64", + "os": "ubuntu-latest", + "unity-version": "6000.6.0b7", + "changeset": "53d4abb44f07", + "channel": "b", + "build-target": "StandaloneLinux64" + }, + { + "name": "windows-latest StandaloneWindows64", + "os": "windows-latest", + "unity-version": "6000.6.0b7", + "changeset": "53d4abb44f07", + "channel": "b", + "build-target": "StandaloneWindows64" + } + ] + } diff --git a/.github/workflows/unity-release-discovery.yml b/.github/workflows/unity-release-discovery.yml new file mode 100644 index 0000000..9a9991f --- /dev/null +++ b/.github/workflows/unity-release-discovery.yml @@ -0,0 +1,21 @@ +name: unity-release-discovery +on: + schedule: + - cron: '0 7 * * 1' # weekly Monday 07:00 UTC + workflow_dispatch: +permissions: + contents: read + issues: write +jobs: + discover: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24' + - name: Discover Unity releases vs matrix + env: + OPEN_ISSUE: '1' + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/unity-release-discovery.cjs diff --git a/README.md b/README.md index 1ea5429..fc0a0ab 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A powerful all-in-one command line utility for the Unity Game Engine. Automate U ## Table of Contents - [Features](#features) +- [Unity version support](docs/unity-version-support.md) - [Installation](#installation) - [Usage](#usage) - [Common Commands](#common-commands) @@ -49,6 +50,8 @@ A powerful all-in-one command line utility for the Unity Game Engine. Automate U ## Features +See [Unity version support](docs/unity-version-support.md) for how CI tiers (supported / preview / untested) map to `setup-unity --channel` and the blocking matrix. + - Install and manage Unity Hub and Unity Editors (multi-platform) - Activate and return Unity licenses (personal, professional, floating) - Create new Unity projects from templates @@ -242,6 +245,7 @@ unity-cli hub editors --installed - `-p`, `--unity-project ` The path to a Unity project or `none` to skip project detection. - `-u`, `--unity-version ` The Unity version to get (e.g. `2020.3.1f1`, `2021.x`, `2022.1.*`, `6000`). If specified, it will override the version read from the project. - `-c`, `--changeset ` The Unity changeset to get (e.g. `1234567890ab`). +- `--channel ` Release channel letter(s) when resolving partial versions: `f`,`p`,`b`,`a`,`x` (comma-separated). Default: `f` (stable only). Partial versions fail closed if no match exists for the requested channel(s). - `-a`, `--arch ` The Unity architecture to get (e.g. `x86_64`, `arm64`). Defaults to the architecture of the current process. - `-b`, `--build-targets ` The Unity build target to get/install as comma-separated values (e.g. `iOS,Android`). - `-m`, `--modules ` The Unity module to get/install as comma-separated values (e.g. `ios,android`). diff --git a/docs/unity-version-support.md b/docs/unity-version-support.md new file mode 100644 index 0000000..6083d68 --- /dev/null +++ b/docs/unity-version-support.md @@ -0,0 +1,20 @@ +# Unity version support + +This repo tests Unity editors on CI in three tiers: + +| Tier | What it means | How CI selects | +|------|----------------|----------------| +| **Supported** | Blocking integration matrix; must stay green for releases | [`.github/workflows/build-options.json`](../.github/workflows/build-options.json) via `setup-unity` with default `--channel f` (stable/`f` only) | +| **Preview** | Best-effort beta canary; may be red (e.g. package TMP import) | [`.github/workflows/unity-preview-canary.yml`](../.github/workflows/unity-preview-canary.yml) — pinned FQ beta + `--channel b`, `continue-on-error` | +| **Untested** | Not in CI; still installable | Fully-qualified `-u 6000.x.yfN -c ` or partial + `--channel b\|a` | + +## Resolving versions + +- Partial versions (`6000.5`, `2021.3.x`) resolve only within the requested channel(s). Default is **stable (`f`)**. +- If no matching stable release exists (e.g. `6000.6` while only betas ship), `setup-unity` **fails closed** instead of letting Hub guess a beta. +- Use `--channel b` (or `a`) when you intentionally want pre-release streams. +- Weekly [unity-release-discovery](../.github/workflows/unity-release-discovery.yml) compares the Releases API to the matrix/canary pin and opens/updates a `unity-release-drift` issue when tips move. + +## Dependencies + +Integration UTP batches install `com.utilities.buildpipeline` from OpenUPM **unpinned** (latest). Preview canary exercises Validate + TMP essentials import against that package. diff --git a/package-lock.json b/package-lock.json index bdd36b0..944cfeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,21 @@ { "name": "@rage-against-the-pixel/unity-cli", - "version": "3.0.1", + "version": "3.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rage-against-the-pixel/unity-cli", - "version": "3.0.1", + "version": "3.0.2", "license": "MIT", "dependencies": { - "@electron/asar": "^4.2.0", - "@rage-against-the-pixel/unity-releases-api": "^1.0.4", + "@electron/asar": "^4.2.1", + "@rage-against-the-pixel/unity-releases-api": "^1.0.5", "commander": "^14.0.3", "glob": "^13.0.6", - "semver": "^7.8.1", + "semver": "^7.8.5", "source-map-support": "^0.5.21", - "tar": "^7.5.15", + "tar": "^7.5.22", "update-notifier": "^7.3.1", "yaml": "^2.9.0" }, @@ -24,11 +24,11 @@ }, "devDependencies": { "@types/jest": "^30.0.0", - "@types/node": "^24.12.4", - "@types/semver": "^7.7.1", + "@types/node": "^24.13.3", + "@types/semver": "^7.8.0", "@types/update-notifier": "^6.0.8", "jest": "^30.4.2", - "ts-jest": "^29.4.11", + "ts-jest": "^29.4.12", "ts-node": "^10.9.2", "typescript": "^6.0.3" }, @@ -103,14 +103,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -243,13 +243,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -513,18 +513,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -532,9 +532,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -577,12 +577,11 @@ } }, "node_modules/@electron/asar": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.2.0.tgz", - "integrity": "sha512-npW1NW5yy8EB9XY/vEw9sUdgmq0sJEhmSBb6bqyFOAw1CSkrhvAvO6QWlW8CdIMo8VN1lkdF345l/MeW0LrY0Q==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.2.1.tgz", + "integrity": "sha512-rGyEe7iy52zxiWuihV/h/AqQrFkx7YnUUJKW1bdufVDHCzIMP/XDrDI/NOzv/LLtzt3NduAzNa475WRmRnmswQ==", "license": "MIT", "dependencies": { - "commander": "^13.1.0", "glob": "^13.0.2", "minimatch": "^10.0.1" }, @@ -593,15 +592,6 @@ "node": ">=22.12.0" } }, - "node_modules/@electron/asar/node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1275,32 +1265,35 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^14.18.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/pkgr" @@ -1334,9 +1327,9 @@ "license": "ISC" }, "node_modules/@pnpm/npm-conf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", - "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.3.tgz", + "integrity": "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==", "license": "MIT", "dependencies": { "@pnpm/config.env-replace": "^1.1.0", @@ -1348,18 +1341,18 @@ } }, "node_modules/@rage-against-the-pixel/unity-releases-api": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@rage-against-the-pixel/unity-releases-api/-/unity-releases-api-1.0.4.tgz", - "integrity": "sha512-jt7yrF5fsMI24nZUamV17IwCWUH/Wd8WO/j3akQXyZ1hMmelkTAEMMNwV3qeqW3DGkipHP1tP2gu5WAgA8ZtTw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@rage-against-the-pixel/unity-releases-api/-/unity-releases-api-1.0.5.tgz", + "integrity": "sha512-BNtvhMsJnzIcELLFTum3Iz+7ZCvHSJQSNd0CRSut8SWWwklhttZCtFCV4LZ4/dTgcTPzKMJMwvxzxagWgz+uJQ==", "license": "MIT", "dependencies": { "jose": "5.10.0" } }, "node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "dev": true, "license": "MIT" }, @@ -1412,9 +1405,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -1513,19 +1506,19 @@ } }, "node_modules/@types/node": { - "version": "24.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", - "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", "dev": true, "license": "MIT" }, @@ -1565,9 +1558,9 @@ "license": "MIT" }, "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "dev": true, "license": "ISC" }, @@ -1885,9 +1878,9 @@ ] }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -2186,9 +2179,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.11.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.11.tgz", + "integrity": "sha512-/yImnXwyTvgMkhgekLHok/Rx5vO6E0BmStWlSqKWMVm2a2ITuZ1Tn+9bgLS+gZRdZmWtd8nxuhHpdmCUOWsTQQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2222,21 +2215,21 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -2254,10 +2247,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -2320,9 +2313,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { @@ -2694,9 +2687,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.361", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", - "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", "dev": true, "license": "ISC" }, @@ -4297,9 +4290,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { @@ -4468,12 +4461,12 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -4557,9 +4550,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, "license": "MIT", "engines": { @@ -4734,9 +4727,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -4750,9 +4743,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -4879,9 +4872,9 @@ }, "node_modules/react-is-19": { "name": "react-is", - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", - "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", "dev": true, "license": "MIT" }, @@ -4946,9 +4939,9 @@ } }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5171,13 +5164,13 @@ } }, "node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.9" + "@pkgr/core": "^0.3.6" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -5187,9 +5180,9 @@ } }, "node_modules/tar": { - "version": "7.5.15", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", - "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -5234,9 +5227,9 @@ "license": "BSD-3-Clause" }, "node_modules/ts-jest": { - "version": "29.4.11", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", - "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", "dev": true, "license": "MIT", "dependencies": { @@ -5246,7 +5239,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.8.0", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -5403,9 +5396,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -5851,9 +5844,9 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 76f8474..127cc1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rage-against-the-pixel/unity-cli", - "version": "3.0.1", + "version": "3.0.2", "description": "A command line utility for the Unity Game Engine.", "author": "RageAgainstThePixel", "license": "MIT", @@ -53,23 +53,23 @@ "unlink": "npm unlink @rage-against-the-pixel/unity-cli" }, "dependencies": { - "@electron/asar": "^4.2.0", - "@rage-against-the-pixel/unity-releases-api": "^1.0.4", + "@electron/asar": "^4.2.1", + "@rage-against-the-pixel/unity-releases-api": "^1.0.5", "commander": "^14.0.3", "glob": "^13.0.6", - "semver": "^7.8.1", + "semver": "^7.8.5", "source-map-support": "^0.5.21", - "tar": "^7.5.15", + "tar": "^7.5.22", "update-notifier": "^7.3.1", "yaml": "^2.9.0" }, "devDependencies": { "@types/jest": "^30.0.0", - "@types/node": "^24.12.4", - "@types/semver": "^7.7.1", + "@types/node": "^24.13.3", + "@types/semver": "^7.8.0", "@types/update-notifier": "^6.0.8", "jest": "^30.4.2", - "ts-jest": "^29.4.11", + "ts-jest": "^29.4.12", "ts-node": "^10.9.2", "typescript": "^6.0.3" }, diff --git a/src/cli.ts b/src/cli.ts index 5cefb07..a5fb9fe 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -358,6 +358,7 @@ program.command('setup-unity') .option('-p, --unity-project ', 'The path to a Unity project or "none" to skip project detection.') .option('-u, --unity-version ', 'The Unity version to get (e.g. 2020.3.1f1, 2021.x, 2022.1.*, 6000). If specified, it will override the version read from the project.') .option('-c, --changeset ', 'The Unity changeset to get (e.g. 1234567890ab).') + .option('--channel ', 'Release channel letter(s) to accept when resolving partial versions: f,p,b,a,x (comma-separated). Default: f (stable only).') .option('-a, --arch ', 'The Unity architecture to get (e.g. x86_64, arm64). Defaults to the architecture of the current process.') .option('-b, --build-targets ', 'The Unity build target to get/install as comma-separated values (e.g. iOS,Android).') .option('-m, --modules ', 'The Unity module to get/install as comma-separated values (e.g. ios,android).') @@ -416,10 +417,23 @@ program.command('setup-unity') } const unityHub = new UnityHub(); - const unityEditor = await unityHub.GetEditor(unityVersion, modules); + const channels: string[] = options.channel + ? String(options.channel).split(/[ ,]+/).map((c: string) => c.trim().toLowerCase()).filter(Boolean) + : ['f']; + const allowedChannels = new Set(['f', 'p', 'b', 'a', 'x']); + for (const ch of channels) { + if (!allowedChannels.has(ch)) { + Logger.instance.error(`Invalid --channel '${ch}'. Expected one of: f, p, b, a, x.`); + process.exit(1); + } + } + const unityEditor = await unityHub.GetEditor(unityVersion, modules, channels); const output: { [key: string]: string } = { 'UNITY_HUB_PATH': unityHub.executable, - 'UNITY_EDITOR_PATH': unityEditor.editorPath + 'UNITY_EDITOR_PATH': unityEditor.editorPath, + 'UNITY_EDITOR_VERSION': unityEditor.version.version, + 'UNITY_EDITOR_CHANGESET': unityEditor.version.changeset ?? '', + 'UNITY_EDITOR_CHANNELS': channels.join(','), }; if (unityProject) { diff --git a/src/logging.ts b/src/logging.ts index 164a802..5d12795 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -1,4 +1,5 @@ import { UTP, Severity } from './utp'; +import { UTP_BENIGN_SEVERITY_REMAPS } from './utp-benign'; import { GitHubActionsLoggerProvider, GitHubAnnotationLevel } from './github-actions-ci'; import { ILoggerProvider, LocalCliLoggerProvider, LoggerAnnotationOptions, MarkdownTarget } from './logger-provider'; @@ -386,18 +387,32 @@ function formatDurationMsForSummary(ms: number | undefined): string { } /** Unity/CI noise shown in logs; omit from workflow summary foldouts and counts. */ -const SUMMARY_NOISE_ACCESS_TOKEN = 'Access token is unavailable; failed to update'; +function buildSummaryNoisePatterns(): RegExp[] { + return UTP_BENIGN_SEVERITY_REMAPS.map(({ fragment }) => { + const escaped = fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Multicast lines often include "(err: 10013)." — strip the whole clause. + if (fragment.includes('multicast group')) { + return new RegExp(`${escaped}(?:\\s*\\(err:\\s*\\d+\\))?\\.?`, 'gi'); + } + return new RegExp(escaped, 'gi'); + }); +} + +const SUMMARY_NOISE_PATTERNS: RegExp[] = buildSummaryNoisePatterns(); /** * Removes known noise phrases from a log message for summary display. - * Exported for unit tests. + * Exported for unit tests. Fragments come from {@link UTP_BENIGN_SEVERITY_REMAPS}. */ export function stripSummaryNoiseFromLogMessage(message: string): string { const flat = toSingleLineText(message); if (!flat) return ''; - const pattern = SUMMARY_NOISE_ACCESS_TOKEN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const out = flat.replace(new RegExp(pattern, 'gi'), ' ').replace(/\s+/g, ' ').trim(); - return out; + let out = flat; + for (const pattern of SUMMARY_NOISE_PATTERNS) { + pattern.lastIndex = 0; + out = out.replace(pattern, ' '); + } + return out.replace(/\s+/g, ' ').trim(); } function filterNoiseFromSummaryLogEntries(entries: UTP[]): UTP[] { diff --git a/src/unity-hub.ts b/src/unity-hub.ts index 7fe1bf8..c1373d3 100644 --- a/src/unity-hub.ts +++ b/src/unity-hub.ts @@ -39,46 +39,108 @@ const MIN_NATIVE_WINDOWS_ARM64_HUB_VERSION = coerce('3.17.0')!; /** Allowed characters in a Debian package version (no shell metacharacters). */ const LINUX_HUB_DEB_VERSION_RE = /^[0-9A-Za-z.+~:-]+$/; +/** Hub 3.20+ Electron Forge deb layout. */ +export const LINUX_HUB_EXECUTABLE_MODERN = '/usr/lib/unityhub/unityhub'; +/** Hub ≤3.19 fpm / electron-builder layout. */ +export const LINUX_HUB_EXECUTABLE_LEGACY = '/opt/unityhub/unityhub'; + +/** + * Resolves the Unity Hub binary on Linux. + * Prefers UNITY_HUB_PATH, then the Hub 3.20+ path, then the legacy /opt path. + * When neither is present (pre-install), defaults to the modern path. + */ +export function resolveLinuxHubExecutable( + envPath: string | undefined = process.env.UNITY_HUB_PATH, + existsSync: (p: string) => boolean = fs.existsSync +): string { + if (envPath !== undefined && envPath.length > 0) { + return envPath; + } + if (existsSync(LINUX_HUB_EXECUTABLE_MODERN)) { + return LINUX_HUB_EXECUTABLE_MODERN; + } + if (existsSync(LINUX_HUB_EXECUTABLE_LEGACY)) { + return LINUX_HUB_EXECUTABLE_LEGACY; + } + return LINUX_HUB_EXECUTABLE_MODERN; +} + /** * Fixed bootstrap for Linux Hub apt repo + update index. No user-controlled interpolation (CodeQL). - * Matches prior `Install` update path (wget | gpg | sudo tee, then sources.list). + * Uses DEB822 .sources (Hub 3.20+) and removes legacy .list to avoid duplicate-source warnings. */ const LINUX_HUB_LINUX_UPDATE_REPO_BOOTSTRAP = `#!/bin/sh set -e wget -qO - https://hub.unity3d.com/linux/keys/public | gpg --dearmor | sudo tee /usr/share/keyrings/Unity_Technologies_ApS.gpg >/dev/null -sudo sh -c 'echo "deb [signed-by=/usr/share/keyrings/Unity_Technologies_ApS.gpg] https://hub.unity3d.com/linux/repos/deb stable main" > /etc/apt/sources.list.d/unityhub.list' +sudo rm -f /etc/apt/sources.list.d/unityhub.list +sudo tee /etc/apt/sources.list.d/unityhub.sources >/dev/null <<'EOF' +Types: deb +URIs: https://hub.unity3d.com/linux/repos/deb +Suites: stable +Components: main +Signed-By: /usr/share/keyrings/Unity_Technologies_ApS.gpg +EOF sudo apt-get update --allow-releaseinfo-change `; /** * First phase of fresh Linux Hub install: machine-id, repo keys, jammy mirror, apt-get update. - * No user-controlled interpolation. + * No user-controlled interpolation. Uses DEB822 .sources (Hub 3.20+). */ const LINUX_HUB_LINUX_INSTALL_BOOTSTRAP = `#!/bin/sh set -e dbus-uuidgen >/etc/machine-id && mkdir -p /var/lib/dbus/ && ln -sf /etc/machine-id /var/lib/dbus/machine-id wget -qO - https://hub.unity3d.com/linux/keys/public | gpg --dearmor | tee /usr/share/keyrings/Unity_Technologies_ApS.gpg >/dev/null -echo "deb [signed-by=/usr/share/keyrings/Unity_Technologies_ApS.gpg] https://hub.unity3d.com/linux/repos/deb stable main" > /etc/apt/sources.list.d/unityhub.list +rm -f /etc/apt/sources.list.d/unityhub.list +tee /etc/apt/sources.list.d/unityhub.sources >/dev/null <<'EOF' +Types: deb +URIs: https://hub.unity3d.com/linux/repos/deb +Suites: stable +Components: main +Signed-By: /usr/share/keyrings/Unity_Technologies_ApS.gpg +EOF echo "deb https://archive.ubuntu.com/ubuntu jammy main universe" | tee /etc/apt/sources.list.d/jammy.list apt-get update `; /** * Post-install cleanup and xvfb / unity-hub wrapper setup. Runs as root; no user interpolation. + * Wrapper resolves Hub 3.20+ (/usr/lib/unityhub) vs legacy (/opt/unityhub) at runtime so apt + * upgrades that move the binary do not leave a stale path (exit 127). */ const LINUX_HUB_LINUX_INSTALL_POST = `#!/bin/sh set -e apt-get clean sed -i 's/^\\(.*DISPLAY=:.*XAUTHORITY=.*\\)\\( "\\$@" \\)2>&1$/\\1\\2/' /usr/bin/xvfb-run -printf '#!/bin/bash\\nxvfb-run --auto-servernum /opt/unityhub/unityhub "$@" 2>/dev/null' | tee /usr/bin/unity-hub >/dev/null -chmod 777 /usr/bin/unity-hub -which unityhub || { echo "Unity Hub installation failed"; exit 1; } -hubPath=$(which unityhub) -if [ -z "$hubPath" ]; then - echo "Failed to install Unity Hub" - exit 1 +command -v unityhub >/dev/null || { echo "Unity Hub installation failed"; exit 1; } +hubPath=$(readlink -f "$(command -v unityhub)" 2>/dev/null || true) +if [ -z "$hubPath" ] || [ ! -x "$hubPath" ]; then + if [ -x /usr/lib/unityhub/unityhub ]; then + hubPath=/usr/lib/unityhub/unityhub + elif [ -x /opt/unityhub/unityhub ]; then + hubPath=/opt/unityhub/unityhub + else + echo "Failed to install Unity Hub" + exit 1 + fi fi -chmod -R 777 "$hubPath" +tee /usr/bin/unity-hub >/dev/null <<'WRAPPER' +#!/bin/bash +if [ -x /usr/lib/unityhub/unityhub ]; then + hubBin=/usr/lib/unityhub/unityhub +elif [ -x /opt/unityhub/unityhub ]; then + hubBin=/opt/unityhub/unityhub +else + hubBin=$(readlink -f "$(command -v unityhub)" 2>/dev/null || true) +fi +if [ -z "$hubBin" ] || [ ! -x "$hubBin" ]; then + echo "Unity Hub binary not found" >&2 + exit 127 +fi +exec xvfb-run --auto-servernum "$hubBin" "$@" 2>/dev/null +WRAPPER +chmod 777 /usr/bin/unity-hub +chmod -R 777 "$(dirname "$hubPath")" `; const LINUX_HUB_LINUX_APT_EXTRAS = [ @@ -93,9 +155,9 @@ const LINUX_HUB_LINUX_APT_EXTRAS = [ export class UnityHub { /** The path to the Unity Hub executable. */ - public readonly executable: string; + public executable!: string; /** The root directory of the Unity Hub installation. */ - public readonly rootDirectory: string; + public rootDirectory!: string; /** The file extension for the Unity editor executable. */ public readonly editorFileExtension: string; @@ -131,8 +193,7 @@ export class UnityHub { this.editorFileExtension = '/Unity.app/Contents/MacOS/Unity'; break; case 'linux': - this.executable = process.env.UNITY_HUB_PATH || '/opt/unityhub/unityhub'; - this.rootDirectory = path.join(this.executable, '../'); + this.refreshLinuxHubPaths(); this.editorFileExtension = '/Editor/Unity'; break; default: @@ -140,6 +201,12 @@ export class UnityHub { } } + /** Re-resolve Linux Hub executable + root after install/upgrade (Hub 3.20 moved under /usr/lib). */ + private refreshLinuxHubPaths(): void { + this.executable = resolveLinuxHubExecutable(); + this.rootDirectory = path.join(this.executable, '../'); + } + /** * Some Hub builds (notably Windows headless) occasionally exit non-zero after streaming usable * `editors --releases` / `editors -i` data. Tolerate only when the captured output parses the same @@ -535,6 +602,9 @@ export class UnityHub { ['apt-get', 'install', '-y', '--no-install-recommends', '--only-upgrade', hubPkg], linuxExecOpts ); + // Refresh xvfb wrapper after upgrades that move /opt → /usr/lib (Hub 3.20+). + await Exec('sudo', ['sh', '-c', LINUX_HUB_LINUX_INSTALL_POST], linuxExecOpts); + this.refreshLinuxHubPaths(); this.logger.info(`Unity Hub updated successfully.`); } else { throw new Error(`Unsupported platform: ${process.platform}`); @@ -544,6 +614,9 @@ export class UnityHub { } } + if (process.platform === 'linux') { + this.refreshLinuxHubPaths(); + } await fs.promises.access(this.executable, fs.constants.X_OK); return this.executable; } @@ -683,6 +756,7 @@ export class UnityHub { linuxExecOpts ); await Exec('sudo', ['sh', '-c', LINUX_HUB_LINUX_INSTALL_POST], linuxExecOpts); + this.refreshLinuxHubPaths(); break; } default: @@ -810,27 +884,49 @@ export class UnityHub { let resolvedVersion = unityVersion; if (!resolvedVersion.isLegacy()) { - try { - if (!resolvedVersion.isFullyQualified()) { + // Hub list is a fast path only. Misses must fall through to the Releases API — + // do not fail-closed until both Hub match and API resolution have failed. + if (!resolvedVersion.isFullyQualified()) { + try { const releases = await this.ListAvailableReleases(); Logger.instance.debug(`Found ${releases.length} available Unity releases, searching channels: ${channels.join(', ')}`); resolvedVersion = resolvedVersion.findMatch(releases, channels); + } catch (hubMatchError) { + this.logger.debug( + `No Hub list match for ${resolvedVersion.toString()} (channels: ${channels.join(', ')}); trying Releases API...\n${hubMatchError}` + ); } + } - if (!resolvedVersion?.changeset) { - const unityReleaseInfo: UnityRelease = await this.GetEditorReleaseInfo(resolvedVersion); - resolvedVersion = new UnityVersion(unityReleaseInfo.version, unityReleaseInfo.shortRevision, resolvedVersion.architecture); - } - } catch (error) { - this.logger.warn(`Failed to get Unity release info for ${resolvedVersion.toString()}! falling back to legacy search...\n${error}`); + if (!resolvedVersion.changeset) { try { - resolvedVersion = await this.fallbackVersionLookup(resolvedVersion); - } catch (fallbackError) { - this.logger.warn(`Failed to lookup changeset for Unity ${resolvedVersion.toString()}!\n${fallbackError}`); + const unityReleaseInfo: UnityRelease = await this.GetEditorReleaseInfo(resolvedVersion, channels); + resolvedVersion = new UnityVersion(unityReleaseInfo.version, unityReleaseInfo.shortRevision, resolvedVersion.architecture); + } catch (error) { + // Fail closed for partial versions: never Hub-install "6000.6" and hope it picks a beta. + if (!resolvedVersion.isFullyQualified()) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to resolve Unity ${unityVersion.toString()} for channel(s) [${channels.join(', ')}]: ${msg}` + ); + } + this.logger.warn(`Failed to get Unity release info for ${resolvedVersion.toString()}! falling back to legacy search...\n${error}`); + try { + resolvedVersion = await this.fallbackVersionLookup(resolvedVersion); + } catch (fallbackError) { + this.logger.warn(`Failed to lookup changeset for Unity ${resolvedVersion.toString()}!\n${fallbackError}`); + } } } } + if (!resolvedVersion.isLegacy() && !resolvedVersion.isFullyQualified()) { + throw new Error( + `Refusing to install non-fully-qualified Unity version ${resolvedVersion.toString()} without a resolved release. ` + + `Use a fully-qualified version or --channel matching an available stream.` + ); + } + const allowPartialMatches = !resolvedVersion.isFullyQualified(); let editorPath = await this.checkInstalledEditors(resolvedVersion, false, undefined, allowPartialMatches); unityVersion = resolvedVersion; @@ -1038,9 +1134,10 @@ done * Gets the specified Unity release info from the Unity Releases API. * Supports querying by exact version or by prefix (e.g., "2020", "2020.1", "2021.x", "2021.3.x"). * @param unityVersion The Unity version to get the release info for. + * @param channels Letter channels to accept (`f`, `p`, `b`, `a`, `x`). Default stable-only. * @returns The Unity release info. */ - public async GetEditorReleaseInfo(unityVersion: UnityVersion): Promise { + public async GetEditorReleaseInfo(unityVersion: UnityVersion, channels: string[] = ['f']): Promise { // Prefer querying the releases API with the exact fully-qualified Unity version (e.g., 2022.3.10f1). // If we don't have a fully-qualified version, use the most specific prefix available: // - "YYYY.M" when provided (e.g., 6000.1) @@ -1061,6 +1158,7 @@ done } const releasesClient = new UnityReleasesClient(); + const channelSet = new Set(channels.map(c => c.toLowerCase())); function getPlatform(): Array<('MAC_OS' | 'LINUX' | 'WINDOWS')> { switch (process.platform) { @@ -1075,6 +1173,11 @@ done } } + function releaseChannelLetter(releaseVersion: string): string | undefined { + const m = /^(\d{1,4})\.(\d+)\.(\d+)([abcfpx])(\d+)$/.exec(releaseVersion); + return m?.[4]; + } + const request: GetUnityReleasesData = { url: '/unity/editor/release/v1/releases', query: { @@ -1099,15 +1202,18 @@ done throw new Error(`No Unity releases found for version: ${version}`); } - // Filter to stable 'f' releases only unless the user explicitly asked for a pre-release - const isExplicitPrerelease = /[abcpx]$/.test(unityVersion.version) || /[abcpx]/.test(unityVersion.version); const releases: ReleaseInfo[] = (data.results || []) .filter((release) => { const v = release.version; if (v == null || v === '') { return false; } - return isExplicitPrerelease || v.includes('f'); + // Exact FQ request: accept that row regardless of channel filter. + if (fullUnityVersionPattern.test(unityVersion.version) && v === unityVersion.version) { + return true; + } + const letter = releaseChannelLetter(v); + return letter != null && channelSet.has(letter); }) .map(release => ({ unityRelease: release, @@ -1115,7 +1221,13 @@ done })); if (releases.length === 0) { - throw new Error(`No suitable Unity releases (stable) found for version: ${version}`); + const channelList = [...channelSet].join(','); + throw new Error( + `No suitable Unity releases (channels: ${channelList}) found for version: ${version}` + + (channelSet.has('f') && channelSet.size === 1 + ? `. No stable (f) release for ${version}; use --channel b/a or a fully-qualified version.` + : '') + ); } releases.sort((a, b) => UnityVersion.compare(b.unityVersion, a.unityVersion)); diff --git a/src/unity-logging.ts b/src/unity-logging.ts index 09db7c3..cbb1d25 100644 --- a/src/unity-logging.ts +++ b/src/unity-logging.ts @@ -20,8 +20,10 @@ import { UTPMemoryLeak, UTPPlayerBuildInfo, UTPTestStatus, - normalizeTelemetryEntry + isElevatedUtpSeverity, + normalizeTelemetryEntry, } from './utp'; +import { utpMessageMatchesBenignRemap } from './utp-benign'; /** * Result of the tailLogFile function containing cleanup resources. @@ -1127,26 +1129,6 @@ async function writeUtpTelemetryLog(filePath: string, entries: UTP[], logger: Lo } } -/** - * Editor log messages whose severity has been changed. - * Useful for making certain error messages that are not critical less noisy. - * Key is a substring of the log message, value is the remapped LogLevel. - */ -const remappedEditorLogs: Record = { - 'OpenCL device, baking cannot use GPU lightmapper.': LogLevel.INFO, - 'Failed to find a suitable OpenCL device, baking cannot use GPU lightmapper.': LogLevel.INFO, - '~StackAllocator(ALLOC_TEMP_MAIN) m_LastAlloc not NULL. Did you forget to call FreeAllStackAllocations()?': LogLevel.INFO, -}; - -function getRemappedEditorLogLevel(message: string): LogLevel | undefined { - for (const [fragment, level] of Object.entries(remappedEditorLogs)) { - if (message.includes(fragment)) { - return level; - } - } - return undefined; -} - /** * Tails a log file using fs.watch and ReadStream for efficient reading. * @param logPath The path to the log file to tail. @@ -1253,15 +1235,7 @@ export function TailLogFile(logPath: string, projectPath: string | undefined): L } } - if (utp.message && 'severity' in utp && - (utp.severity === Severity.Error || utp.severity === Severity.Exception || utp.severity === Severity.Assert)) { - let messageLevel: LogLevel = LogLevel.ERROR; - - const remappedLevel = getRemappedEditorLogLevel(utp.message); - if (remappedLevel !== undefined) { - messageLevel = remappedLevel; - } - + if (utp.message && 'severity' in utp && isElevatedUtpSeverity(utp.severity)) { const normalizedPath = normalizeAnnotationPath(utp.file, projectPath); const stacktrace = sanitizeStackTrace(utp.stackTrace); const message = stacktrace == undefined ? utp.message : `${utp.message}\n${stacktrace}`; @@ -1279,20 +1253,16 @@ export function TailLogFile(logPath: string, projectPath: string | undefined): L } } } else { - switch (messageLevel) { - case LogLevel.WARN: - logger.warn(message); - break; - case LogLevel.ERROR: - logger.error(message); - break; - case LogLevel.INFO: - default: - logger.info(message); - break; - } + logger.error(message); } } + } else if (utp.message && utpMessageMatchesBenignRemap(utp.message)) { + // Remapped at normalize time (e.g. multicast WSAEACCES); surface as info, not error. + const stacktrace = sanitizeStackTrace(utp.stackTrace); + const message = stacktrace == undefined ? utp.message : `${utp.message}\n${stacktrace}`; + if (!annotationCommandPrefixRegex.test(message)) { + logger.info(message); + } } else if (Logger.instance.logLevel === LogLevel.UTP) { printUTP(utp); } @@ -1300,8 +1270,21 @@ export function TailLogFile(logPath: string, projectPath: string | undefined): L logger.warn(`Failed to parse telemetry JSON: ${error} -- raw: ${jsonPart}`); } } else { + // Skip plain-log false positives (e.g. "Socket: bind failed, error: …" matching \berror\b). + if (utpMessageMatchesBenignRemap(line)) { + if (Logger.instance.logLevel !== LogLevel.UTP) { + process.stdout.write(`${line}\n`); + } + return; + } const scan = parsePlainLogIssue(line); if (scan) { + if (utpMessageMatchesBenignRemap(scan.message)) { + if (Logger.instance.logLevel !== LogLevel.UTP) { + process.stdout.write(`${line}\n`); + } + return; + } const key = buildIssueKey(scan.file, scan.line, scan.message); if (!seenIssueKeys.has(key)) { seenIssueKeys.add(key); diff --git a/src/unity-version.ts b/src/unity-version.ts index dae0ff8..455af99 100644 --- a/src/unity-version.ts +++ b/src/unity-version.ts @@ -31,6 +31,13 @@ export class UnityVersion { changeset: string | null | undefined = undefined, architecture: 'X86_64' | 'ARM64' | undefined = undefined ) { + // Accept ProjectVersion / matrix style: "5.6.7f1 (e80cc3114ac1)" (no regex: avoid ReDoS). + const embedded = UnityVersion.tryParseEmbeddedChangeset(version); + if (embedded) { + version = embedded.version; + changeset = changeset ?? embedded.changeset; + } + this.version = version; this.changeset = changeset; this.semVer = UnityVersion.createSemVer(version); @@ -103,10 +110,16 @@ export class UnityVersion { this.logger.debug(`Found Unity ${latest.version}`); return new UnityVersion(latest.version, null, this.architecture); } + + throw new Error( + `No Unity release matching ${this.version} for channel(s) [${channels.join(', ')}]. ` + + (channels.length === 1 && channels[0] === 'f' + ? `No stable (f) release for ${this.version}; use --channel b/a or a fully-qualified version (e.g. 6000.6.0b7).` + : `Try a different --channel or a fully-qualified version.`) + ); } - this.logger.debug(`No matching Unity version found for ${this.version}`); - return this; + throw new Error(`No matching Unity version found for ${this.version}`); } satisfies(version: UnityVersion): boolean { @@ -144,6 +157,37 @@ export class UnityVersion { private static readonly UNITY_RELEASE_PATTERN = /^(\d{1,4})\.(\d+)\.(\d+)([abcfpx])(\d+)$/; private static readonly VERSION_TOKEN_PATTERN = /^(\d{1,4})(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?/; + /** + * Parses trailing " (hexchangeset)" without regex to avoid ReDoS on hostile input. + */ + private static tryParseEmbeddedChangeset(raw: string): { version: string; changeset: string } | null { + if (!raw.endsWith(')')) { + return null; + } + const open = raw.lastIndexOf('('); + if (open <= 0 || raw[open - 1] !== ' ') { + return null; + } + const hex = raw.slice(open + 1, -1); + if (hex.length === 0) { + return null; + } + for (let i = 0; i < hex.length; i++) { + const c = hex.charCodeAt(i); + const isHex = + (c >= 48 && c <= 57) || // 0-9 + (c >= 97 && c <= 102) || // a-f + (c >= 65 && c <= 70); // A-F + if (!isHex) { + return null; + } + } + return { + version: raw.slice(0, open - 1).trimEnd(), + changeset: hex, + }; + } + private static readonly UNITY_CHANNEL_ORDER: Record = { a: 0, b: 1, diff --git a/src/utp-benign.ts b/src/utp-benign.ts new file mode 100644 index 0000000..b463997 --- /dev/null +++ b/src/utp-benign.ts @@ -0,0 +1,41 @@ +/** + * Known Unity/editor messages that are non-actionable despite elevated UTP severity. + * Kept in a leaf module (no imports) so normalize, summaries, and CI share one list + * without circular deps between utp.ts and logging.ts. + * + * Severity strings must match {@link Severity} in utp.ts. + */ +export const UTP_BENIGN_SEVERITY_REMAPS: ReadonlyArray<{ + readonly fragment: string; + readonly severity: 'Info' | 'Warning'; +}> = [ + // Longer OpenCL form first so summary strip does not leave a "Failed to find a suitable" prefix. + { fragment: 'Failed to find a suitable OpenCL device, baking cannot use GPU lightmapper.', severity: 'Info' }, + { fragment: 'OpenCL device, baking cannot use GPU lightmapper.', severity: 'Info' }, + { + fragment: + '~StackAllocator(ALLOC_TEMP_MAIN) m_LastAlloc not NULL. Did you forget to call FreeAllStackAllocations()?', + severity: 'Info', + }, + // Windows hosted CI: WSAEACCES (10013) — player-connection multicast / socket bind. Unity falls back. + { fragment: 'Unable to join player connection multicast group', severity: 'Info' }, + { fragment: 'Socket: bind failed', severity: 'Info' }, + { + fragment: 'An attempt was made to access a socket in a way forbidden by its access permissions', + severity: 'Info', + }, + { fragment: 'Access token is unavailable; failed to update', severity: 'Info' }, +]; + +/** True if the message matches a known benign Unity/CI noise fragment. */ +export function utpMessageMatchesBenignRemap(message: string): boolean { + if (!message) { + return false; + } + for (const { fragment } of UTP_BENIGN_SEVERITY_REMAPS) { + if (message.includes(fragment)) { + return true; + } + } + return false; +} diff --git a/src/utp.ts b/src/utp.ts index f917ad5..9ce1dfd 100644 --- a/src/utp.ts +++ b/src/utp.ts @@ -1,4 +1,10 @@ import { Logger } from "./logging"; +import { UTP_BENIGN_SEVERITY_REMAPS } from './utp-benign'; + +export { + UTP_BENIGN_SEVERITY_REMAPS, + utpMessageMatchesBenignRemap, +} from './utp-benign'; export class UTPBase { type?: string; @@ -153,6 +159,30 @@ export enum Severity { Assert = 'Assert' } +/** Severities that normally fail builds / CI expected-success checks. */ +export function isElevatedUtpSeverity(severity: Severity | string | undefined): boolean { + return severity === Severity.Error + || severity === Severity.Exception + || severity === Severity.Assert; +} + +/** + * Downgrades elevated severity on known benign messages. Mutates `utp`. + * @returns true when severity was changed. + */ +export function remapBenignUtpSeverity(utp: UTP): boolean { + if (!utp.message || !isElevatedUtpSeverity(utp.severity)) { + return false; + } + for (const { fragment, severity } of UTP_BENIGN_SEVERITY_REMAPS) { + if (utp.message.includes(fragment)) { + utp.severity = severity as Severity; + return true; + } + } + return false; +} + /** * Root-level JSON keys on UTP objects that this CLI recognizes. Other keys are still parsed * but reported via {@link normalizeTelemetryEntry}'s `unknownTopLevelKeys` for logging. @@ -197,8 +227,9 @@ export interface NormalizeTelemetryResult { } /** - * Normalizes UTP telemetry entries to canonical shapes. Unknown top-level keys are listed - * for the caller to log (with the raw `##utp:` line when tailing logs). + * Normalizes UTP telemetry entries to canonical shapes and remaps known benign elevated + * severities. Unknown top-level keys are listed for the caller to log (with the raw + * `##utp:` line when tailing logs). */ export function normalizeTelemetryEntry(entry: unknown): NormalizeTelemetryResult { if (!entry || typeof entry !== 'object') { @@ -234,6 +265,18 @@ export function normalizeTelemetryEntry(entry: unknown): NormalizeTelemetryResul utp.lineNumber = utp.line; } + // Canonicalize severity string casing from Unity payloads. + if (typeof utp.severity === 'string') { + const matched = (Object.values(Severity) as string[]).find( + s => s.toLowerCase() === (utp.severity as string).toLowerCase() + ); + if (matched) { + utp.severity = matched as Severity; + } + } + + remapBenignUtpSeverity(utp); + if (!utp.type) { Logger.instance.warn('UTP entry missing type property; telemetry entry may be ignored.'); } diff --git a/tests/linux-hub-path.test.ts b/tests/linux-hub-path.test.ts new file mode 100644 index 0000000..68a95eb --- /dev/null +++ b/tests/linux-hub-path.test.ts @@ -0,0 +1,34 @@ +import { + LINUX_HUB_EXECUTABLE_LEGACY, + LINUX_HUB_EXECUTABLE_MODERN, + resolveLinuxHubExecutable, +} from '../src/unity-hub'; + +describe('resolveLinuxHubExecutable', () => { + it('prefers UNITY_HUB_PATH when set', () => { + const exists = jest.fn(() => true); + expect(resolveLinuxHubExecutable('/custom/unityhub', exists)).toBe('/custom/unityhub'); + expect(exists).not.toHaveBeenCalled(); + }); + + it('ignores empty UNITY_HUB_PATH and uses filesystem candidates', () => { + const exists = (p: string) => p === LINUX_HUB_EXECUTABLE_LEGACY; + expect(resolveLinuxHubExecutable('', exists)).toBe(LINUX_HUB_EXECUTABLE_LEGACY); + expect(resolveLinuxHubExecutable(undefined, exists)).toBe(LINUX_HUB_EXECUTABLE_LEGACY); + }); + + it('prefers Hub 3.20+ /usr/lib layout over legacy /opt', () => { + const exists = (p: string) => + p === LINUX_HUB_EXECUTABLE_MODERN || p === LINUX_HUB_EXECUTABLE_LEGACY; + expect(resolveLinuxHubExecutable(undefined, exists)).toBe(LINUX_HUB_EXECUTABLE_MODERN); + }); + + it('falls back to legacy /opt when modern path is missing', () => { + const exists = (p: string) => p === LINUX_HUB_EXECUTABLE_LEGACY; + expect(resolveLinuxHubExecutable(undefined, exists)).toBe(LINUX_HUB_EXECUTABLE_LEGACY); + }); + + it('defaults to modern path when neither layout is installed', () => { + expect(resolveLinuxHubExecutable(undefined, () => false)).toBe(LINUX_HUB_EXECUTABLE_MODERN); + }); +}); diff --git a/tests/logging-summary.test.ts b/tests/logging-summary.test.ts index cef432a..7b6b040 100644 --- a/tests/logging-summary.test.ts +++ b/tests/logging-summary.test.ts @@ -28,6 +28,20 @@ describe('stripSummaryNoiseFromLogMessage', () => { ); expect(stripSummaryNoiseFromLogMessage('Access token is unavailable; failed to update')).toBe(''); }); + + it('removes player-connection multicast noise', () => { + expect(stripSummaryNoiseFromLogMessage('Unable to join player connection multicast group (err: 10013).')).toBe(''); + expect( + stripSummaryNoiseFromLogMessage('Before. Unable to join player connection multicast group (err: 10013). After.') + ).toBe('Before. After.'); + }); + + it('removes OpenCL / access-token noise phrases from the shared remap list', () => { + expect(stripSummaryNoiseFromLogMessage('Access token is unavailable; failed to update')).toBe(''); + expect( + stripSummaryNoiseFromLogMessage('Failed to find a suitable OpenCL device, baking cannot use GPU lightmapper.') + ).toBe(''); + }); }); describe('mergeLogEntriesPreferringSeverity', () => { diff --git a/tests/run-utp-tests-contract.sh b/tests/run-utp-tests-contract.sh index e4531d8..e1a9de9 100644 --- a/tests/run-utp-tests-contract.sh +++ b/tests/run-utp-tests-contract.sh @@ -1,8 +1,13 @@ #!/usr/bin/env bash # Contract tests for UTP CI assertion helpers (bash; run on Linux CI or Git Bash). +# Requires dist/utp.js (npm run build) — helpers parse UTP via normalizeTelemetryEntry. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if [ ! -f "$ROOT/dist/utp.js" ]; then + echo "Building dist/ for UTP normalize…" >&2 + (cd "$ROOT" && npm run build) +fi # shellcheck source=../.github/actions/scripts/utp-ci-assertion-helpers.sh source "$ROOT/.github/actions/scripts/utp-ci-assertion-helpers.sh" @@ -25,6 +30,71 @@ if ! utp_signals_failure_for_expected_success CompilerWarnings "$tmpdir/warn-err fail "CompilerWarnings + Error should signal failure for expected-success check" fi +# Windows GHA multicast permission noise — remapped to Info by normalizeTelemetryEntry. +printf '%s\n' '{"type":"Log","severity":"Error","message":"Unable to join player connection multicast group (err: 10013)."}' >"$tmpdir/multicast.json" +if utp_signals_failure_for_expected_success CompilerWarnings "$tmpdir/multicast.json"; then + fail "CompilerWarnings + player-connection multicast Error should be remapped (not actionable)" +fi + +# Unity 2019-style sibling WSAEACCES line (same CI failure mode as multicast). +printf '%s\n' '{"type":"Log","severity":"Error","message":"Socket: bind failed, error: An attempt was made to access a socket in a way forbidden by its access permissions."}' >"$tmpdir/socket-bind.json" +if utp_signals_failure_for_expected_success CompilerWarnings "$tmpdir/socket-bind.json"; then + fail "CompilerWarnings + Socket bind failed should be remapped (not actionable)" +fi +if utp_signals_failure_for_expected_success BuildWarnings "$tmpdir/socket-bind.json"; then + fail "BuildWarnings + Socket bind failed should be remapped (not actionable)" +fi +if utp_signals_failure_for_expected_success BuildWarnings "$tmpdir/multicast.json"; then + fail "BuildWarnings + player-connection multicast Error should be remapped (not actionable)" +fi +if utp_signals_failure_for_expected_success EditmodeTestsPassing "$tmpdir/multicast.json"; then + fail "Expected-success scenarios should treat remapped multicast as non-actionable" +fi +if utp_signals_any_severity_problem "$tmpdir/multicast.json"; then + fail "utp_signals_any_severity_problem should treat remapped multicast as non-actionable" +fi + +# Pretty-printed CLI artifact shape (writeUtpTelemetryLog) must parse + remap the same way. +cat >"$tmpdir/multicast-pretty.json" <<'EOF' +[ + { + "type": "LogEntry", + "severity": "Error", + "message": "Unable to join player connection multicast group (err: 10013)." + } +] +EOF +if utp_signals_failure_for_expected_success CompilerWarnings "$tmpdir/multicast-pretty.json"; then + fail "Pretty-printed multicast UTP artifact should remap Error → Info" +fi + +# Mixed: multicast noise + real Error still fails. +printf '%s\n' \ + '{"type":"Log","severity":"Error","message":"Unable to join player connection multicast group (err: 10013)."}' \ + '{"type":"Log","severity":"Error","message":"boom"}' >"$tmpdir/multicast-plus-real.json" +if ! utp_signals_failure_for_expected_success CompilerWarnings "$tmpdir/multicast-plus-real.json"; then + fail "Real Error alongside multicast noise should still signal failure" +fi + +# Pretty mixed array +cat >"$tmpdir/multicast-plus-real-pretty.json" <<'EOF' +[ + { + "type": "LogEntry", + "severity": "Error", + "message": "Unable to join player connection multicast group (err: 10013)." + }, + { + "type": "LogEntry", + "severity": "Error", + "message": "boom" + } +] +EOF +if ! utp_signals_failure_for_expected_success CompilerWarnings "$tmpdir/multicast-plus-real-pretty.json"; then + fail "Pretty-printed real Error alongside multicast should still signal failure" +fi + printf '%s\n' '{"severity":"Assert"}' >"$tmpdir/nonwarn-assert.json" if ! utp_signals_failure_for_expected_success EditmodeTestsPassing "$tmpdir/nonwarn-assert.json"; then fail "Non-warning scenario should still treat Assert as failure for expected-success check" @@ -63,4 +133,33 @@ if edit_play_log_suggests_tests_completed_ok EditmodeTestsPassing EditMode; then fail "edit_play_log_suggests_tests_completed_ok should reject logs that also contain failure markers" fi +# --- run-utp-tests.sh harness contracts (no Unity) --- +UTP_RUNNER="$ROOT/.github/actions/scripts/run-utp-tests.sh" +if ! grep -q 'set -euo pipefail' "$UTP_RUNNER"; then + fail "run-utp-tests.sh must use set -euo pipefail" +fi +if grep -qE '^[[:space:]]*declare -A' "$UTP_RUNNER"; then + fail "run-utp-tests.sh must not use declare -A (macOS bash 3.2)" +fi +if ! grep -q 'scenarios_run' "$UTP_RUNNER"; then + fail "run-utp-tests.sh must track scenarios_run / zero-artifact guard" +fi +# Membership check mirrors is_known_utp_test in run-utp-tests.sh +is_known_utp_test() { + case "$1" in + CompilerWarnings|CompilerErrors|BuildWarnings|BuildErrors|PlaymodeTestsErrors|EditmodeTestsErrors|EditmodeTestsPassing|EditmodeTestsSkipped|PlaymodeTestsPassing|PlaymodeTestsSkipped|EditmodeSuite|PlaymodeSuite) + return 0 + ;; + *) + return 1 + ;; + esac +} +if ! is_known_utp_test CompilerWarnings; then + fail "CompilerWarnings should be a known UTP test" +fi +if is_known_utp_test NotARealTest; then + fail "NotARealTest should not be a known UTP test" +fi + echo "run-utp-tests-contract: OK" diff --git a/tests/unity-hub-release-api-filter.test.ts b/tests/unity-hub-release-api-filter.test.ts index 3b8580b..2b8a46e 100644 --- a/tests/unity-hub-release-api-filter.test.ts +++ b/tests/unity-hub-release-api-filter.test.ts @@ -62,4 +62,21 @@ describe('UnityHub GetEditorReleaseInfo (sparse API rows)', () => { const hub = new UnityHub(); await expect(hub.GetEditorReleaseInfo(new UnityVersion('2021'))).rejects.toThrow(/No suitable Unity releases/); }); + + it('accepts beta releases when channel b is requested', async () => { + mockGetUnityReleases.mockResolvedValue({ + data: { + results: [ + { version: '6000.6.0b7', shortRevision: '53d4abb44f07' }, + { version: '6000.5.7f1', shortRevision: 'stable' }, + ], + }, + error: undefined, + }); + + const hub = new UnityHub(); + const info = await hub.GetEditorReleaseInfo(new UnityVersion('6000.6'), ['b']); + expect(info.version).toBe('6000.6.0b7'); + expect(info.shortRevision).toBe('53d4abb44f07'); + }); }); diff --git a/tests/unity-version.test.ts b/tests/unity-version.test.ts index c0e8a4c..bb9ac03 100644 --- a/tests/unity-version.test.ts +++ b/tests/unity-version.test.ts @@ -9,6 +9,20 @@ describe('UnityVersion', () => { expect(UnityVersion.compare(f2, version)).toBeGreaterThan(0); }); + it('parses embedded changeset from version (changeset) strings', () => { + const version = new UnityVersion('5.6.7f1 (e80cc3114ac1)'); + expect(version.version).toBe('5.6.7f1'); + expect(version.changeset).toBe('e80cc3114ac1'); + expect(version.isFullyQualified()).toBe(true); + expect(version.toString()).toBe('5.6.7f1 (e80cc3114ac1)'); + }); + + it('keeps explicit changeset over embedded parenthetical', () => { + const version = new UnityVersion('5.6.7f1 (e80cc3114ac1)', 'aaaaaaaaaaaa'); + expect(version.version).toBe('5.6.7f1'); + expect(version.changeset).toBe('aaaaaaaaaaaa'); + }); + it('orders Unity builds by channel and revision', () => { const alpha = new UnityVersion('2021.3.5a1'); const beta = new UnityVersion('2021.3.5b1'); @@ -101,27 +115,43 @@ describe('UnityVersion', () => { expect(match.version).toBe('2021.3.5p2'); }); - it('returns original when no channel candidates exist', () => { + it('throws when no channel candidates exist', () => { const available = [ '2021.3.5f2' ]; const version = new UnityVersion('2021.3.x'); - const match = version.findMatch(available, ['a']); - - expect(match.version).toBe('2021.3.x'); + expect(() => version.findMatch(available, ['a'])).toThrow(/No Unity release matching/); }); - it('keeps explicit minor requests when no matching releases are available', () => { + it('throws when explicit minor has no matching releases', () => { const available = [ '6000.3.0f1' ]; const version = new UnityVersion('6000.0.x'); - const match = version.findMatch(available); + expect(() => version.findMatch(available)).toThrow(/No Unity release matching/); + }); - // When only other minors exist (e.g., 6000.3.*), do not fall back to them. - expect(match.version).toBe('6000.0.x'); + it('throws for stable channel when only beta exists (6000.6)', () => { + const available = [ + '6000.6.0b7', + '6000.5.7f1', + '6000.7.0a4', + ]; + const version = new UnityVersion('6000.6'); + expect(() => version.findMatch(available, ['f'])).toThrow(/No stable \(f\) release/); + }); + + it('resolves beta channel for 6000.6 when only b releases exist', () => { + const available = [ + '6000.6.0b5', + '6000.6.0b7', + '6000.5.7f1', + ]; + const version = new UnityVersion('6000.6'); + const match = version.findMatch(available, ['b']); + expect(match.version).toBe('6000.6.0b7'); }); it('evaluates caret compatibility with satisfies', () => { diff --git a/tests/utp-telemetry-fixtures.test.ts b/tests/utp-telemetry-fixtures.test.ts index ef705f7..d1b57b3 100644 --- a/tests/utp-telemetry-fixtures.test.ts +++ b/tests/utp-telemetry-fixtures.test.ts @@ -1,6 +1,10 @@ import * as fs from 'fs'; import * as path from 'path'; -import { normalizeTelemetryEntry, UTP_SUPPORTED_TOP_LEVEL_PROPERTIES } from '../src/utp'; +import { + normalizeTelemetryEntry, + Severity, + UTP_SUPPORTED_TOP_LEVEL_PROPERTIES, +} from '../src/utp'; import { buildTestResultsTableMarkdown, utpToTestResultSummary } from '../src/logging'; import { formatUtpUnrecognizedTopLevelPropertiesMessage } from '../src/unity-logging'; @@ -72,6 +76,68 @@ describe('UTP telemetry fixtures', () => { }); }); +describe('normalizeTelemetryEntry benign severity remaps', () => { + it('remaps player-connection multicast Error to Info', () => { + const { utp } = normalizeTelemetryEntry({ + type: 'LogEntry', + severity: 'Error', + message: 'Unable to join player connection multicast group (err: 10013).', + }); + expect(utp.severity).toBe(Severity.Info); + }); + + it('remaps Socket bind failed / WSAEACCES permission Errors to Info', () => { + const bind = normalizeTelemetryEntry({ + type: 'LogEntry', + severity: 'Error', + message: + 'Socket: bind failed, error: An attempt was made to access a socket in a way forbidden by its access permissions.', + }).utp; + expect(bind.severity).toBe(Severity.Info); + + const plainExtracted = normalizeTelemetryEntry({ + type: 'LogEntry', + severity: Severity.Error, + message: 'An attempt was made to access a socket in a way forbidden by its access permissions.', + }).utp; + expect(plainExtracted.severity).toBe(Severity.Info); + }); + + it('remaps OpenCL and StackAllocator elevated severities to Info', () => { + const opencl = normalizeTelemetryEntry({ + type: 'LogEntry', + severity: Severity.Error, + message: 'Failed to find a suitable OpenCL device, baking cannot use GPU lightmapper.', + }).utp; + expect(opencl.severity).toBe(Severity.Info); + + const stack = normalizeTelemetryEntry({ + type: 'LogEntry', + severity: Severity.Assert, + message: '~StackAllocator(ALLOC_TEMP_MAIN) m_LastAlloc not NULL. Did you forget to call FreeAllStackAllocations()?', + }).utp; + expect(stack.severity).toBe(Severity.Info); + }); + + it('leaves real Errors unchanged', () => { + const { utp } = normalizeTelemetryEntry({ + type: 'Compiler', + severity: 'Error', + message: 'Assets/Foo.cs(1,1): error CS0001: boom', + }); + expect(utp.severity).toBe(Severity.Error); + }); + + it('canonicalizes severity casing', () => { + const { utp } = normalizeTelemetryEntry({ + type: 'LogEntry', + severity: 'error', + message: 'real failure', + }); + expect(utp.severity).toBe(Severity.Error); + }); +}); + describe('formatUtpUnrecognizedTopLevelPropertiesMessage', () => { it('includes unknown key names and the full ##utp line', () => { const line = '##utp:{"type":"Action","extra":1}';