From 1882f5493fbf98abea71c82cf2fccaca9a57bc54 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 30 Jul 2026 07:32:11 +0000 Subject: [PATCH 01/22] ci: centralize Unity enrollment lifecycle --- .../actions/return-unity-license/action.yml | 183 ----------- .github/dependabot.yml | 10 +- .github/unity-versions.json | 2 +- .github/workflows/perf-numbers.yml | 190 +++++------ .github/workflows/release-prepare.yml | 6 +- .github/workflows/release-tag.yml | 12 +- .github/workflows/release.yml | 275 ++++++++-------- .github/workflows/unity-benchmarks.yml | 198 +++++------- .github/workflows/unity-gameci-experiment.yml | 266 --------------- .github/workflows/unity-tests.yml | 302 +++++------------- .llm/index.json | 6 +- ...enchmarks-run-in-highest-fidelity-scope.md | 6 +- .../references/unity-perf-test-isolation.md | 8 +- .../github-workflow-consistency/SKILL.md | 2 +- .../release-asset-and-notes-invariants.md | 2 +- .../il2cpp-build-configuration/SKILL.md | 5 +- ...erf-config-il2cpp-release-netstandard21.md | 22 +- .llm/skills/unity-editor-ci/SKILL.md | 19 +- .../references/unity-ci-matrix.md | 41 ++- .../references/unity-version-single-source.md | 70 ++-- .llm/skills/unity-licensing/SKILL.md | 8 +- .../references/unity-license-bootstrap.md | 10 +- .../unity-license-return-guarantee.md | 20 +- docs/ops/ambiguous-release-migration.md | 17 +- docs/ops/ci-and-github-settings.md | 3 +- docs/ops/npm-release-publishing.md | 18 +- docs/ops/release-operations.md | 15 +- docs/runbooks/perf-benchmark-methodology.md | 31 +- docs/runbooks/perf-numbers-auto-commit.md | 7 +- docs/runbooks/required-checks.md | 9 +- .../__tests__/ci-aggregate-workflow.test.js | 189 +++++++++-- .../__tests__/validate-unity-versions.test.js | 19 ++ scripts/validate-unity-pr-policy.py | 90 ++++-- scripts/validate-unity-versions.js | 47 ++- 34 files changed, 837 insertions(+), 1271 deletions(-) delete mode 100644 .github/actions/return-unity-license/action.yml delete mode 100644 .github/workflows/unity-gameci-experiment.yml diff --git a/.github/actions/return-unity-license/action.yml b/.github/actions/return-unity-license/action.yml deleted file mode 100644 index 1b9b8871..00000000 --- a/.github/actions/return-unity-license/action.yml +++ /dev/null @@ -1,183 +0,0 @@ -name: Return Unity license -description: >- - Return the runner's Unity activation, capture private evidence, and delegate - cleanup classification to the central organization policy. -inputs: - unity-editor-path: - description: "Path to the resolved Unity editor executable. When empty, only attested prior return evidence can prove cleanup." - required: false - prior-return-log-path: - description: "Non-uploaded return log produced by the licensed command." - required: false - prior-command-succeeded: - description: "Set true only when the command that produced prior-return-log-path completed successfully." - required: false - default: "false" - evidence-paths: - description: "Newline-separated current-run files or directories used only for supplemental account-health evidence." - required: false -outputs: - resource-safe: - description: "Whether central policy confirmed that this runner no longer holds the license activation." - value: ${{ steps.classify_return.outputs.resource-safe || steps.classify_prior.outputs.resource-safe }} - resource-cleanup-status: - description: "confirmed only when central policy observed exact positive Unity return evidence; otherwise unknown." - value: ${{ steps.classify_return.outputs.resource-cleanup-status || steps.classify_prior.outputs.resource-cleanup-status }} - resource-health: - description: "Central account-health classification." - value: ${{ steps.classify_return.outputs.resource-health || steps.classify_prior.outputs.resource-health }} - resource-reason: - description: "Stable central cleanup evidence reason code." - value: ${{ steps.classify_return.outputs.resource-reason || steps.classify_prior.outputs.resource-reason }} - classification-complete: - description: "Whether bounded central classification completed deterministically." - value: ${{ steps.classify_return.outputs.classification-complete || steps.classify_prior.outputs.classification-complete }} - evidence-digest: - description: "SHA-256 digest of bounded evidence identity and bytes." - value: ${{ steps.classify_return.outputs.evidence-digest || steps.classify_prior.outputs.evidence-digest }} - return-log-path: - description: "Private return log selected for classification and later deletion." - value: ${{ steps.capture_return.outputs.return-log-path || steps.prior_metadata.outputs.return-log-path }} - supplemental-evidence-paths: - description: "Private supplemental evidence selected for classification and later deletion." - value: ${{ steps.prior_metadata.outputs.supplemental-evidence-paths }} -runs: - using: composite - steps: - - name: Resolve prior return metadata - id: prior_metadata - shell: pwsh - env: - PRIOR_RETURN_LOG_PATH: ${{ inputs.prior-return-log-path }} - PRIOR_COMMAND_SUCCEEDED: ${{ inputs.prior-command-succeeded }} - EVIDENCE_PATHS: ${{ inputs.evidence-paths }} - run: | - Set-StrictMode -Version Latest - $ErrorActionPreference = 'Stop' - $ready = $false - $exitCode = '' - $returnLogPath = '' - $supplementalPaths = [System.Collections.Generic.List[string]]::new() - foreach ($evidencePath in @($env:EVIDENCE_PATHS -split "[`r`n]+")) { - if (-not [string]::IsNullOrWhiteSpace($evidencePath)) { - $supplementalPaths.Add($evidencePath) - } - } - try { - $candidate = [string]$env:PRIOR_RETURN_LOG_PATH - if ($candidate -match "[`r`n]") { - throw 'Prior return evidence path contains an unsupported newline.' - } - if (-not [string]::IsNullOrWhiteSpace($candidate) -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { - $file = Get-Item -LiteralPath $candidate -Force - if ($file.Length -gt 25MB -or $file.Extension -notin @('.log', '.txt')) { - throw 'Prior return evidence is outside the supported bounds.' - } - if ($env:PRIOR_COMMAND_SUCCEEDED -eq 'true') { - $lastLine = @( - Get-Content -LiteralPath $file.FullName -Tail 4 -ErrorAction Stop | - Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } - ) | Select-Object -Last 1 - if ([string]$lastLine -match '^exit_return_rc=(-?[0-9]+)$') { - $parsedExitCode = 0L - if ([long]::TryParse($Matches[1], [ref]$parsedExitCode)) { - $ready = $true - $exitCode = [string]$parsedExitCode - $returnLogPath = $file.FullName - } - } - } - # Retain the prior file for fallback health classification and - # deletion even when its exact-return metadata was attested. - $supplementalPaths.Add($file.FullName) - } - } catch { - Write-Host '::warning::Prior Unity return metadata could not be attested; the redundant return remains authoritative.' - } - "prior-ready=$($ready.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "return-exit-code=$exitCode" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "return-log-path=$returnLogPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - $delimiter = "DXM_EVIDENCE_$([guid]::NewGuid().ToString('N'))" - "supplemental-evidence-paths<<$delimiter" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - $supplementalPaths | Sort-Object -Unique | Out-File -FilePath $env:GITHUB_OUTPUT -Append - $delimiter | Out-File -FilePath $env:GITHUB_OUTPUT -Append - - - name: Classify prior exact return evidence - id: classify_prior - if: ${{ steps.prior_metadata.outputs.prior-ready == 'true' }} - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - return-log-path: ${{ steps.prior_metadata.outputs.return-log-path }} - return-command-completed: "true" - return-exit-code: ${{ steps.prior_metadata.outputs.return-exit-code }} - evidence-capture-complete: "true" - supplemental-evidence-paths: ${{ steps.prior_metadata.outputs.supplemental-evidence-paths }} - - - name: Capture redundant Unity return evidence - id: capture_return - if: ${{ always() && steps.classify_prior.outputs.resource-safe != 'true' }} - shell: pwsh - env: - UNITY_EDITOR_INPUT: ${{ inputs.unity-editor-path }} - run: | - Set-StrictMode -Version Latest - $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $false - $licenseLogDir = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } - $returnLog = Join-Path $licenseLogDir 'unity-return-license.log' - $commandCompleted = $false - $exitCode = '' - $captureComplete = $false - try { - Set-Content -LiteralPath $returnLog -Value '' -NoNewline - $captureComplete = $true - $editorPath = [string]$env:UNITY_EDITOR_INPUT - if ([string]::IsNullOrWhiteSpace($editorPath)) { - $editorPath = [string]$env:UNITY_EDITOR_PATH - } - if ([string]::IsNullOrWhiteSpace($editorPath)) { - Write-Host '::notice::No Unity editor path resolved; cleanup remains unknown.' - } elseif (-not (Test-Path -LiteralPath $editorPath -PathType Leaf)) { - Write-Host '::notice::Resolved Unity editor path does not exist; cleanup remains unknown.' - } elseif ([string]::IsNullOrWhiteSpace($env:UNITY_EMAIL) -or [string]::IsNullOrWhiteSpace($env:UNITY_PASSWORD)) { - Write-Host '::warning::UNITY_EMAIL/UNITY_PASSWORD are not both set; cleanup remains unknown.' - } else { - $returnArgs = @( - '-quit', - '-batchmode', - '-nographics', - '-returnlicense', - '-username', $env:UNITY_EMAIL, - '-password', $env:UNITY_PASSWORD, - '-logFile', '-' - ) - Write-Host '::group::Return Unity license' - try { - & $editorPath @returnArgs 2>&1 | Out-File -FilePath $returnLog -Encoding utf8 - $exitCode = [string]$LASTEXITCODE - $commandCompleted = $true - Add-Content -LiteralPath $returnLog -Value "exit_return_rc=$exitCode" -Encoding utf8 - Write-Host "::notice::Unity return command completed with exit code $exitCode; raw evidence remains private." - } finally { - Write-Host '::endgroup::' - } - } - } catch { - $captureComplete = $false - Write-Host '::warning::Unity return evidence capture failed; cleanup remains unknown and central release will fail closed.' - } - "return-log-path=$returnLog" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "return-command-completed=$($commandCompleted.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "return-exit-code=$exitCode" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "evidence-capture-complete=$($captureComplete.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - - - name: Classify redundant return evidence - id: classify_return - if: ${{ always() && steps.classify_prior.outputs.resource-safe != 'true' }} - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - return-log-path: ${{ steps.capture_return.outputs.return-log-path }} - return-command-completed: ${{ steps.capture_return.outputs.return-command-completed }} - return-exit-code: ${{ steps.capture_return.outputs.return-exit-code }} - evidence-capture-complete: ${{ steps.capture_return.outputs.evidence-capture-complete }} - supplemental-evidence-paths: ${{ steps.prior_metadata.outputs.supplemental-evidence-paths }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 760e7179..e4404fa7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,16 +2,8 @@ version: 2 updates: # GitHub Actions workflow updates. # - # A bare "/" makes Dependabot scan .github/workflows plus a repository-root - # action.yml, and nothing else. The build-lock pin inside - # .github/actions/return-unity-license/action.yml therefore sat outside every - # bump, so a bump split the centralized Unity cleanup policy across two SHAs. - # Listing that directory keeps the whole group on one pin; the split now fails - # scripts/__tests__/ci-aggregate-workflow.test.js closed. - package-ecosystem: "github-actions" - directories: - - "/" - - "/.github/actions/return-unity-license" + directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 2 diff --git a/.github/unity-versions.json b/.github/unity-versions.json index 6708b086..1454ec63 100644 --- a/.github/unity-versions.json +++ b/.github/unity-versions.json @@ -1,5 +1,5 @@ { - "_comment": "Canonical Unity version source of truth for all CI. The newest entry of `all` (last element) is the 'latest' tracked by perf-numbers/unity-benchmarks. `release` is the version release.yml and unity-gameci-experiment.yml pin. Bump versions HERE; scripts/validate-unity-versions.js fails if any consumer drifts. See .llm/skills/unity-editor-ci/SKILL.md for the contract.", + "_comment": "Canonical Unity version source of truth for all CI. The newest entry of `all` (last element) is the 'latest' tracked by perf-numbers. `release` is the version release.yml pins. Bump versions HERE; scripts/validate-unity-versions.js fails if any static workflow matrix or other consumer drifts. See .llm/skills/unity-editor-ci/SKILL.md for the contract.", "all": ["2021.3.45f1", "2022.3.45f1", "6000.3.16f1"], "release": "2022.3.45f1" } diff --git a/.github/workflows/perf-numbers.yml b/.github/workflows/perf-numbers.yml index 44b1e614..67dd8f41 100644 --- a/.github/workflows/perf-numbers.yml +++ b/.github/workflows/perf-numbers.yml @@ -45,7 +45,6 @@ on: push: branches: - master - - main # Loop break: the commit-perf-doc job pushes a doc + baseline commit with a # GitHub App token, which (unlike the built-in GITHUB_TOKEN) DOES re-trigger # workflows. Ignoring BOTH the perf doc AND the regenerated baseline CSV here @@ -85,16 +84,8 @@ jobs: group: ${{ github.workflow }}-runner-preflight-${{ github.ref }} cancel-in-progress: true outputs: - latest-version: ${{ steps.versions.outputs.latest-version }} - unity-versions: ${{ steps.versions.outputs.unity-versions }} has-autocommit-app: ${{ steps.check-autocommit-app.outputs.has-app }} steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 1 - persist-credentials: false - - name: Require an online performance runner uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/check-unity-runner-availability@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: @@ -102,21 +93,6 @@ jobs: reader-app-private-key: ${{ secrets.BUILD_LOCK_READER_APP_PRIVATE_KEY }} required-label-sets: '[["self-hosted","Windows","RAM-64GB","fast"]]' - - name: Resolve Unity version from canonical source - id: versions - # Single source of truth: .github/unity-versions.json. perf tracks the - # LATEST entry only (last element of `all`); emitting it here means the - # matrix leg, the artifact-download pattern, and render-perf-doc.js all - # consume ONE value and cannot drift. See scripts/validate-unity-versions.js. - run: | - set -euo pipefail - file=.github/unity-versions.json - latest="$(jq -r '.all[-1]' "${file}")" - { - printf 'latest-version=%s\n' "${latest}" - printf 'unity-versions=%s\n' "$(jq -c '[.all[-1]]' "${file}")" - } >> "${GITHUB_OUTPUT}" - - name: Check for the auto-commit GitHub App credentials id: check-autocommit-app # Gates the post-merge commit job so a repo that has not yet provisioned @@ -144,30 +120,23 @@ jobs: - runner-preflight # Licensed Unity runs only after protected code lands or through a controlled # default-branch dispatch; pull requests are not a workflow trigger. - # NON-BLOCKING PlayMode leg. The Standalone leg is the throughput HEADLINE + - # baseline source and MUST gate publishing; the PlayMode leg only supplies the - # (best-effort) allocation counts. Marking the PlayMode leg continue-on-error - # makes a flaky PlayMode failure report SUCCESS for the matrix-aggregating - # `needs.perf-benchmarks.result`, so the downstream commit job (gated on - # result == 'success') still publishes the Standalone headline + - # baseline; that run simply omits the allocation/byte tables (no leg measured - # them) until PlayMode is healthy again. `fail-fast: false` (below) still keeps a Standalone failure - # from cancelling an in-flight PlayMode leg. - continue-on-error: ${{ matrix.test-mode != 'standalone' }} + # Both the Standalone headline and PlayMode allocation leg are required. + # `fail-fast: false` below lets the sibling finish and preserves diagnostics + # when either leg fails. # Pinned to ELI-MACHINE via the `fast` label (the only self-hosted Windows # runner carrying it). Keep in sync with the preflight REQUIRED_LABELS. runs-on: [self-hosted, Windows, RAM-64GB, fast] timeout-minutes: 660 strategy: fail-fast: false + max-parallel: 1 matrix: # LATEST Unity version only -- the documented numbers track the newest - # supported runtime. The single latest version is resolved from the - # canonical source (.github/unity-versions.json) via runner-preflight, so - # the matrix leg, the artifact-download pattern, and render-perf-doc.js all - # consume ONE value and cannot drift. Single-entry matrix (not a bare - # scalar) so the leg name still carries the version. - unity-version: ${{ fromJSON(needs.runner-preflight.outputs.unity-versions) }} + # supported runtime. This static mirror is enforced against + # .github/unity-versions.json by validate-unity-versions.js and remains + # statically attestable by the organization lock analyzer. + unity-version: + - 6000.3.16f1 # BOTH legs publish. Standalone IL2CPP (Release player) is the throughput # HEADLINE -- the AOT backend shipped games run. The in-editor PlayMode # (Mono) leg supplies the REAL GC-allocation numbers: a Release player @@ -305,7 +274,6 @@ jobs: - name: Acquire organization Unity lock id: acquire_lock - if: ${{ steps.compute.outputs.is-empty != 'true' }} uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: lock-name: wallstop-organization-builds @@ -317,7 +285,7 @@ jobs: BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - name: Require acquired Unity lock - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired != 'true' }} + if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} shell: pwsh run: exit 1 @@ -361,69 +329,6 @@ jobs: -ArtifactsPath '.artifacts/unity/perf/${{ matrix.unity-version }}-${{ matrix.test-mode }}' ` @extra - - name: Return Unity license - id: return_unity_license - if: always() - timeout-minutes: 5 - continue-on-error: true - uses: ./.github/actions/return-unity-license - with: - prior-return-log-path: ${{ runner.temp }}/unity-return-${{ matrix.unity-version }}-${{ matrix.test-mode }}.log - prior-command-succeeded: ${{ steps.run_tests.outcome == 'success' }} - evidence-paths: | - ${{ runner.temp }}/unity-activate-${{ matrix.unity-version }}-${{ matrix.test-mode }}.log - .artifacts/unity/perf/${{ matrix.unity-version }}-${{ matrix.test-mode }} - env: - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - - - name: Release organization Unity lock - id: release_unity_lock - if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - lock-name: wallstop-organization-builds - holder-id-suffix: perf-${{ matrix.unity-version }}-${{ matrix.test-mode }} - runner-id: ${{ runner.name }} - resource-cleanup-status: ${{ steps.return_unity_license.outputs.resource-cleanup-status }} - resource-health: ${{ steps.return_unity_license.outputs.resource-health }} - resource-reason: ${{ steps.return_unity_license.outputs.resource-reason }} - env: - BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} - BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - - - name: Require confirmed Unity cleanup - if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - acquired: ${{ steps.acquire_lock.outputs.acquired }} - classification-complete: ${{ steps.return_unity_license.outputs.classification-complete }} - cleanup-status: ${{ steps.return_unity_license.outputs.resource-cleanup-status }} - cleanup-health: ${{ steps.return_unity_license.outputs.resource-health }} - cleanup-reason: ${{ steps.return_unity_license.outputs.resource-reason }} - release-outcome: ${{ steps.release_unity_lock.outcome }} - cleanup-result: ${{ steps.release_unity_lock.outputs.cleanup-result }} - released: ${{ steps.release_unity_lock.outputs.released }} - release-health: ${{ steps.release_unity_lock.outputs.resource-health }} - release-reason: ${{ steps.release_unity_lock.outputs.resource-reason }} - reservation-state: ${{ steps.release_unity_lock.outputs.reservation-state }} - reservation-id: ${{ steps.release_unity_lock.outputs.reservation-id }} - incident-id: ${{ steps.release_unity_lock.outputs.incident-id }} - - - name: Delete private Unity cleanup evidence - if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} - shell: pwsh - env: - RETURN_LOG_PATH: ${{ steps.return_unity_license.outputs.return-log-path }} - SUPPLEMENTAL_EVIDENCE_PATHS: ${{ steps.return_unity_license.outputs.supplemental-evidence-paths }} - run: | - $evidencePaths = @($env:RETURN_LOG_PATH) + @($env:SUPPLEMENTAL_EVIDENCE_PATHS -split "[`r`n]+") - foreach ($evidencePath in @($evidencePaths | Sort-Object -Unique)) { - if (-not [string]::IsNullOrWhiteSpace($evidencePath) -and (Test-Path -LiteralPath $evidencePath -PathType Leaf)) { - Remove-Item -LiteralPath $evidencePath -Force - } - } - - name: Capture exact standalone player size if: ${{ matrix.test-mode == 'standalone' && steps.run_tests.outcome == 'success' }} shell: pwsh @@ -481,11 +386,84 @@ jobs: if-no-files-found: warn retention-days: 14 + - name: Return Unity license + id: return_unity_license + if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} + timeout-minutes: 5 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + unity-version: ${{ matrix.unity-version }} + tool-cache: ${{ runner.tool_cache }} + unity-email: ${{ secrets.UNITY_EMAIL }} + unity-password: ${{ secrets.UNITY_PASSWORD }} + evidence-suffix: perf + + - name: Classify Unity cleanup evidence + id: cleanup_classification + if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} + timeout-minutes: 2 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + return-log-path: ${{ steps.return_unity_license.outputs.return-log-path }} + return-command-completed: ${{ steps.return_unity_license.outputs.return-command-completed }} + return-exit-code: ${{ steps.return_unity_license.outputs.return-exit-code }} + evidence-capture-complete: ${{ steps.return_unity_license.outputs.evidence-capture-complete }} + return-log-digest: ${{ steps.return_unity_license.outputs.return-log-digest }} + + - name: Release organization Unity lock + id: release_unity_lock + if: always() + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + lock-name: wallstop-organization-builds + holder-id-suffix: perf-${{ matrix.unity-version }}-${{ matrix.test-mode }} + runner-id: ${{ runner.name }} + resource-cleanup-status: ${{ steps.cleanup_classification.outputs.resource-cleanup-status }} + resource-health: ${{ steps.cleanup_classification.outputs.resource-health }} + resource-reason: ${{ steps.cleanup_classification.outputs.resource-reason }} + env: + BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} + BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} + + - name: Require confirmed Unity cleanup + if: always() + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + acquired: ${{ steps.acquire_lock.outputs.acquired }} + classification-complete: ${{ steps.cleanup_classification.outputs.classification-complete }} + cleanup-status: ${{ steps.cleanup_classification.outputs.resource-cleanup-status }} + cleanup-health: ${{ steps.cleanup_classification.outputs.resource-health }} + cleanup-reason: ${{ steps.cleanup_classification.outputs.resource-reason }} + release-outcome: ${{ steps.release_unity_lock.outcome }} + cleanup-result: ${{ steps.release_unity_lock.outputs.cleanup-result }} + released: ${{ steps.release_unity_lock.outputs.released }} + release-health: ${{ steps.release_unity_lock.outputs.resource-health }} + release-reason: ${{ steps.release_unity_lock.outputs.resource-reason }} + reservation-state: ${{ steps.release_unity_lock.outputs.reservation-state }} + reservation-id: ${{ steps.release_unity_lock.outputs.reservation-id }} + incident-id: ${{ steps.release_unity_lock.outputs.incident-id }} + + perf-unity-success: + name: Performance Unity Success + if: always() + needs: + - runner-preflight + - perf-benchmarks + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require complete performance validation + shell: bash + run: | + test "${{ needs.runner-preflight.result }}" = success + test "${{ needs.perf-benchmarks.result }}" = success + commit-perf-doc: name: Commit refreshed perf doc to the default branch needs: - runner-preflight - perf-benchmarks + - perf-unity-success # DEFAULT-BRANCH REFRESH path: after a PR merges (push event) -- or on a # manual workflow_dispatch from the default branch, which is the supported # RECOVERY path when an earlier auto-commit failed -- re-render the doc from @@ -533,7 +511,7 @@ jobs: permissions: contents: read env: - LATEST_VERSION: ${{ needs.runner-preflight.outputs.latest-version }} + LATEST_VERSION: 6000.3.16f1 PERF_DOC: docs/architecture/performance.md # The committed master baseline that the regression gate + PR delta comment # compare against. Regenerated from the real post-merge run and committed diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 49cb9650..513fbeac 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -7,8 +7,8 @@ name: Release Prepare # version heading, syncs the banner SVG, re-runs the script tests and # validators against the bumped tree, and opens a `release/vX.Y.Z` pull # request with the GitHub App token. Merging that PR (squash, keeping the -# `release: vX.Y.Z` title) triggers release-tag.yml, which creates the tag -# that fires the existing tag-triggered release.yml. +# `release: vX.Y.Z` title) triggers release-tag.yml, which creates the tag. +# An operator then selects that exact tag and dispatches release.yml. # # PREREQUISITE: the AUTO_COMMIT_APP_ID / AUTO_COMMIT_APP_PRIVATE_KEY GitHub # App (Contents + Pull requests read-and-write, installed on this repo); the @@ -266,7 +266,7 @@ jobs: echo "## What happens after merge" echo echo "1. release-tag.yml sees the \`release: ${TAG}\` commit on ${DEFAULT_BRANCH} and pushes the annotated tag \`${TAG}\`." - echo "1. The tag fires release.yml: validate (pack + attest), the trusted Unity editmode check, the .unitypackage export," + echo "1. Select the exact \`${TAG}\` ref and manually dispatch release.yml: validate (pack + attest), the trusted Unity editmode check, the .unitypackage export," echo " npm publish (Trusted Publishing), then the GitHub Release with the .tgz, .sha256, and .unitypackage assets" echo " plus the asset-store-submission artifact." echo "1. The Unity Asset Store upload stays MANUAL: download the asset-store-submission artifact and follow docs/runbooks/asset-store-publishing.md." diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index fe1a84e8..db57bebd 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -1,11 +1,11 @@ name: Release Tag -# Bridges the release PR to the tag-triggered release pipeline. When a +# Bridges the release PR to the controlled release dispatch. When a # release-prepare PR merges (squash, default title), the default branch gains # a commit whose subject is `release: vX.Y.Z` and whose package.json version # is X.Y.Z. This workflow notices that commit and pushes the annotated tag -# vX.Y.Z at it with the GitHub App token (a GITHUB_TOKEN tag push would NOT -# re-trigger workflows, so the App token is what makes release.yml fire). +# vX.Y.Z at it with the GitHub App token. An operator then selects that exact +# tag and dispatches release.yml; tag creation alone never publishes. # # LOOP SAFETY: the trigger is a BRANCH push filtered to package.json; the tag # push this job performs is a tag ref and cannot re-trigger it. The @@ -104,9 +104,7 @@ jobs: id: check-autocommit-app if: ${{ steps.detect.outputs.proceed == 'true' }} # Mirrors the perf-numbers.yml probe. Without the App this degrades to - # a ::warning:: carrying the exact manual commands -- a GITHUB_TOKEN - # tag push would not trigger release.yml, so there is no point pushing - # the tag with it. + # a ::warning:: carrying the exact manual commands. env: AUTO_COMMIT_APP_ID: ${{ secrets.AUTO_COMMIT_APP_ID }} AUTO_COMMIT_APP_PRIVATE_KEY: ${{ secrets.AUTO_COMMIT_APP_PRIVATE_KEY }} @@ -171,4 +169,4 @@ jobs: echo "::error::${message}" exit "${push_status}" fi - echo "::notice::Pushed ${TAG} at ${GITHUB_SHA}; the tag-triggered Release workflow takes over from here." + echo "::notice::Pushed ${TAG} at ${GITHUB_SHA}; select this tag and manually dispatch the Release workflow." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa10afc4..9e1d4e02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,7 @@ name: Release on: - push: - tags: - - "v[0-9]*.[0-9]*.[0-9]*" + workflow_dispatch: concurrency: group: release-${{ github.ref_name }} @@ -31,6 +29,10 @@ jobs: id: verify run: | set -euo pipefail + if [ "${GITHUB_REF_TYPE}" != "tag" ]; then + echo "::error::Release must be dispatched from a tag ref, not ${GITHUB_REF_TYPE}." + exit 1 + fi tag="${GITHUB_REF_NAME}" if ! printf '%s\n' "${tag}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then echo "::error::Release tags must use vX.Y.Z semver format." @@ -262,7 +264,6 @@ jobs: - name: Acquire organization Unity lock id: acquire_lock - if: ${{ steps.compute.outputs.is-empty != 'true' }} uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: lock-name: wallstop-organization-builds @@ -274,7 +275,7 @@ jobs: BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - name: Require acquired Unity lock - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired != 'true' }} + if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} shell: pwsh run: exit 1 @@ -298,69 +299,6 @@ jobs: -ReleaseCodeOptimization ` -ReleasePlayerBuild - - name: Return Unity license - id: return_unity_license - if: always() - timeout-minutes: 5 - continue-on-error: true - uses: ./.github/actions/return-unity-license - with: - prior-return-log-path: ${{ runner.temp }}/unity-return-2022.3.45f1-editmode.log - prior-command-succeeded: ${{ steps.run_tests.outcome == 'success' }} - evidence-paths: | - ${{ runner.temp }}/unity-activate-2022.3.45f1-editmode.log - .artifacts/unity/release-editmode - env: - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - - - name: Release organization Unity lock - id: release_unity_lock - if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - lock-name: wallstop-organization-builds - holder-id-suffix: release-editmode - runner-id: ${{ runner.name }} - resource-cleanup-status: ${{ steps.return_unity_license.outputs.resource-cleanup-status }} - resource-health: ${{ steps.return_unity_license.outputs.resource-health }} - resource-reason: ${{ steps.return_unity_license.outputs.resource-reason }} - env: - BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} - BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - - - name: Require confirmed Unity cleanup - if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - acquired: ${{ steps.acquire_lock.outputs.acquired }} - classification-complete: ${{ steps.return_unity_license.outputs.classification-complete }} - cleanup-status: ${{ steps.return_unity_license.outputs.resource-cleanup-status }} - cleanup-health: ${{ steps.return_unity_license.outputs.resource-health }} - cleanup-reason: ${{ steps.return_unity_license.outputs.resource-reason }} - release-outcome: ${{ steps.release_unity_lock.outcome }} - cleanup-result: ${{ steps.release_unity_lock.outputs.cleanup-result }} - released: ${{ steps.release_unity_lock.outputs.released }} - release-health: ${{ steps.release_unity_lock.outputs.resource-health }} - release-reason: ${{ steps.release_unity_lock.outputs.resource-reason }} - reservation-state: ${{ steps.release_unity_lock.outputs.reservation-state }} - reservation-id: ${{ steps.release_unity_lock.outputs.reservation-id }} - incident-id: ${{ steps.release_unity_lock.outputs.incident-id }} - - - name: Delete private Unity cleanup evidence - if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} - shell: pwsh - env: - RETURN_LOG_PATH: ${{ steps.return_unity_license.outputs.return-log-path }} - SUPPLEMENTAL_EVIDENCE_PATHS: ${{ steps.return_unity_license.outputs.supplemental-evidence-paths }} - run: | - $evidencePaths = @($env:RETURN_LOG_PATH) + @($env:SUPPLEMENTAL_EVIDENCE_PATHS -split "[`r`n]+") - foreach ($evidencePath in @($evidencePaths | Sort-Object -Unique)) { - if (-not [string]::IsNullOrWhiteSpace($evidencePath) -and (Test-Path -LiteralPath $evidencePath -PathType Leaf)) { - Remove-Item -LiteralPath $evidencePath -Force - } - } - # CANCELLATION + TIMEOUT DIAGNOSTIC: when Unity 2022 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 @@ -403,6 +341,63 @@ jobs: if-no-files-found: warn retention-days: 14 + - name: Return Unity license + id: return_unity_license + if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} + timeout-minutes: 5 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + unity-version: 2022.3.45f1 + tool-cache: ${{ runner.tool_cache }} + unity-email: ${{ secrets.UNITY_EMAIL }} + unity-password: ${{ secrets.UNITY_PASSWORD }} + evidence-suffix: release-editmode + + - name: Classify Unity cleanup evidence + id: cleanup_classification + if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} + timeout-minutes: 2 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + return-log-path: ${{ steps.return_unity_license.outputs.return-log-path }} + return-command-completed: ${{ steps.return_unity_license.outputs.return-command-completed }} + return-exit-code: ${{ steps.return_unity_license.outputs.return-exit-code }} + evidence-capture-complete: ${{ steps.return_unity_license.outputs.evidence-capture-complete }} + return-log-digest: ${{ steps.return_unity_license.outputs.return-log-digest }} + + - name: Release organization Unity lock + id: release_unity_lock + if: always() + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + lock-name: wallstop-organization-builds + holder-id-suffix: release-editmode + runner-id: ${{ runner.name }} + resource-cleanup-status: ${{ steps.cleanup_classification.outputs.resource-cleanup-status }} + resource-health: ${{ steps.cleanup_classification.outputs.resource-health }} + resource-reason: ${{ steps.cleanup_classification.outputs.resource-reason }} + env: + BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} + BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} + + - name: Require confirmed Unity cleanup + if: always() + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + acquired: ${{ steps.acquire_lock.outputs.acquired }} + classification-complete: ${{ steps.cleanup_classification.outputs.classification-complete }} + cleanup-status: ${{ steps.cleanup_classification.outputs.resource-cleanup-status }} + cleanup-health: ${{ steps.cleanup_classification.outputs.resource-health }} + cleanup-reason: ${{ steps.cleanup_classification.outputs.resource-reason }} + release-outcome: ${{ steps.release_unity_lock.outcome }} + cleanup-result: ${{ steps.release_unity_lock.outputs.cleanup-result }} + released: ${{ steps.release_unity_lock.outputs.released }} + release-health: ${{ steps.release_unity_lock.outputs.resource-health }} + release-reason: ${{ steps.release_unity_lock.outputs.resource-reason }} + reservation-state: ${{ steps.release_unity_lock.outputs.reservation-state }} + reservation-id: ${{ steps.release_unity_lock.outputs.reservation-id }} + incident-id: ${{ steps.release_unity_lock.outputs.incident-id }} + unitypackage: name: Export .unitypackage # Runs AFTER unity-checks so the licensed editmode gate has already proven @@ -521,46 +516,80 @@ jobs: -OutputPath $output ` -ArtifactsPath '.artifacts/unity/release-unitypackage' + - name: Dump Unity log tail on failure or cancellation + if: ${{ failure() || cancelled() }} + uses: ./.github/actions/dump-unity-log-tail + with: + results-dir: .artifacts/unity/release-unitypackage + label: Unity release unitypackage export + + - name: Upload the .unitypackage artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-unitypackage + path: .artifacts/unity/release-unitypackage-dist/ + if-no-files-found: error + retention-days: 14 + overwrite: true + + - name: Upload export diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: unity-release-unitypackage + path: .artifacts/unity/release-unitypackage + if-no-files-found: warn + retention-days: 14 + overwrite: true + - name: Return Unity license id: return_unity_license - if: always() + if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 5 - continue-on-error: true - uses: ./.github/actions/return-unity-license - with: - prior-return-log-path: ${{ runner.temp }}/unity-return-2022.3.45f1-unitypackage.log - prior-command-succeeded: ${{ steps.export_unitypackage.outcome == 'success' }} - evidence-paths: | - ${{ runner.temp }}/unity-activate-2022.3.45f1-unitypackage.log - .artifacts/unity/release-unitypackage - env: - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + unity-version: 2022.3.45f1 + tool-cache: ${{ runner.tool_cache }} + unity-email: ${{ secrets.UNITY_EMAIL }} + unity-password: ${{ secrets.UNITY_PASSWORD }} + evidence-suffix: release-unitypackage + + - name: Classify Unity cleanup evidence + id: cleanup_classification + if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} + timeout-minutes: 2 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + return-log-path: ${{ steps.return_unity_license.outputs.return-log-path }} + return-command-completed: ${{ steps.return_unity_license.outputs.return-command-completed }} + return-exit-code: ${{ steps.return_unity_license.outputs.return-exit-code }} + evidence-capture-complete: ${{ steps.return_unity_license.outputs.evidence-capture-complete }} + return-log-digest: ${{ steps.return_unity_license.outputs.return-log-digest }} - name: Release organization Unity lock id: release_unity_lock if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@08fc83e83fa4cae89c0177005b388585ffdb1d9a with: lock-name: wallstop-organization-builds holder-id-suffix: release-unitypackage runner-id: ${{ runner.name }} - resource-cleanup-status: ${{ steps.return_unity_license.outputs.resource-cleanup-status }} - resource-health: ${{ steps.return_unity_license.outputs.resource-health }} - resource-reason: ${{ steps.return_unity_license.outputs.resource-reason }} + resource-cleanup-status: ${{ steps.cleanup_classification.outputs.resource-cleanup-status }} + resource-health: ${{ steps.cleanup_classification.outputs.resource-health }} + resource-reason: ${{ steps.cleanup_classification.outputs.resource-reason }} env: BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - name: Require confirmed Unity cleanup - if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 + if: always() + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@08fc83e83fa4cae89c0177005b388585ffdb1d9a with: acquired: ${{ steps.acquire_lock.outputs.acquired }} - classification-complete: ${{ steps.return_unity_license.outputs.classification-complete }} - cleanup-status: ${{ steps.return_unity_license.outputs.resource-cleanup-status }} - cleanup-health: ${{ steps.return_unity_license.outputs.resource-health }} - cleanup-reason: ${{ steps.return_unity_license.outputs.resource-reason }} + classification-complete: ${{ steps.cleanup_classification.outputs.classification-complete }} + cleanup-status: ${{ steps.cleanup_classification.outputs.resource-cleanup-status }} + cleanup-health: ${{ steps.cleanup_classification.outputs.resource-health }} + cleanup-reason: ${{ steps.cleanup_classification.outputs.resource-reason }} release-outcome: ${{ steps.release_unity_lock.outcome }} cleanup-result: ${{ steps.release_unity_lock.outputs.cleanup-result }} released: ${{ steps.release_unity_lock.outputs.released }} @@ -570,45 +599,35 @@ jobs: reservation-id: ${{ steps.release_unity_lock.outputs.reservation-id }} incident-id: ${{ steps.release_unity_lock.outputs.incident-id }} - - name: Delete private Unity cleanup evidence - if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} - shell: pwsh - env: - RETURN_LOG_PATH: ${{ steps.return_unity_license.outputs.return-log-path }} - SUPPLEMENTAL_EVIDENCE_PATHS: ${{ steps.return_unity_license.outputs.supplemental-evidence-paths }} + release-checks-success: + name: Release Checks Success + if: always() + needs: + - runner-preflight + - unity-checks + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require release test validation + shell: bash run: | - $evidencePaths = @($env:RETURN_LOG_PATH) + @($env:SUPPLEMENTAL_EVIDENCE_PATHS -split "[`r`n]+") - foreach ($evidencePath in @($evidencePaths | Sort-Object -Unique)) { - if (-not [string]::IsNullOrWhiteSpace($evidencePath) -and (Test-Path -LiteralPath $evidencePath -PathType Leaf)) { - Remove-Item -LiteralPath $evidencePath -Force - } - } - - - name: Dump Unity log tail on failure or cancellation - if: ${{ failure() || cancelled() }} - uses: ./.github/actions/dump-unity-log-tail - with: - results-dir: .artifacts/unity/release-unitypackage - label: Unity release unitypackage export - - - name: Upload the .unitypackage artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: release-unitypackage - path: .artifacts/unity/release-unitypackage-dist/ - if-no-files-found: error - retention-days: 14 - overwrite: true + test "${{ needs.runner-preflight.result }}" = success + test "${{ needs.unity-checks.result }}" = success - - name: Upload export diagnostics - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: unity-release-unitypackage - path: .artifacts/unity/release-unitypackage - if-no-files-found: warn - retention-days: 14 - overwrite: true + release-export-success: + name: Release Export Success + if: always() + needs: + - runner-preflight + - unitypackage + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require release export validation + shell: bash + run: | + test "${{ needs.runner-preflight.result }}" = success + test "${{ needs.unitypackage.result }}" = success publish: name: Publish npm package and GitHub Release @@ -617,6 +636,8 @@ jobs: - validate - unity-checks - unitypackage + - release-checks-success + - release-export-success # ATOMIC RELEASE: the .unitypackage is a REQUIRED asset, so publish runs only # when every upstream job -- including unitypackage -- succeeds (default # needs-gating; no always() escape hatch). A failed export therefore blocks diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml index 740ef4b1..1214867e 100644 --- a/.github/workflows/unity-benchmarks.yml +++ b/.github/workflows/unity-benchmarks.yml @@ -1,15 +1,7 @@ name: Unity Benchmarks 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 @@ -18,53 +10,15 @@ on: # documented dispatch-throughput numbers are owned entirely by the Performance # Numbers workflow (.github/workflows/perf-numbers.yml), which posts the refreshed # numbers as a non-blocking PR comment and, after merge, commits the refreshed -# default-branch doc directly via a GitHub App installation token; this scheduled -# workflow keeps the per-version benchmark -# test coverage and does not maintain the doc, so it needs no write scope. +# default-branch doc directly via a GitHub App installation token. This +# manually dispatched workflow keeps per-version benchmark test coverage and +# does not maintain the doc, so it needs no write scope. 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 }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - 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 (exposed as - # latest-version for reference; the documented doc table is now - # maintained by the per-PR Performance Numbers workflow, - # .github/workflows/perf-numbers.yml). 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}" - runner-preflight: name: Self-hosted runner access preflight runs-on: ubuntu-latest @@ -83,7 +37,6 @@ jobs: benchmarks: name: Benchmarks ${{ matrix.unity-version }} ${{ matrix.test-mode }} needs: - - matrix-config - runner-preflight runs-on: [self-hosted, Windows, RAM-64GB] timeout-minutes: 660 @@ -91,7 +44,10 @@ jobs: fail-fast: false max-parallel: 1 matrix: - unity-version: ${{ fromJSON(needs.matrix-config.outputs.unity-versions) }} + unity-version: + - 2021.3.45f1 + - 2022.3.45f1 + - 6000.3.16f1 test-mode: - editmode - playmode @@ -198,7 +154,6 @@ jobs: - name: Acquire organization Unity lock id: acquire_lock - if: ${{ steps.compute.outputs.is-empty != 'true' }} uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: lock-name: wallstop-organization-builds @@ -210,7 +165,7 @@ jobs: BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - name: Require acquired Unity lock - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired != 'true' }} + if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} shell: pwsh run: exit 1 @@ -228,7 +183,7 @@ jobs: 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. + # coverage run measures the same optimized hot path the docs do. # This run keeps its editmode+playmode + no-comparisons coverage role; # the Release flags add no comparison rows. ./scripts/unity/run-ci-tests.ps1 ` @@ -239,69 +194,6 @@ jobs: -ReleaseCodeOptimization ` -ReleasePlayerBuild - - name: Return Unity license - id: return_unity_license - if: always() - timeout-minutes: 5 - continue-on-error: true - uses: ./.github/actions/return-unity-license - with: - prior-return-log-path: ${{ runner.temp }}/unity-return-${{ matrix.unity-version }}-${{ matrix.test-mode }}.log - prior-command-succeeded: ${{ steps.run_tests.outcome == 'success' }} - evidence-paths: | - ${{ runner.temp }}/unity-activate-${{ matrix.unity-version }}-${{ matrix.test-mode }}.log - .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }} - env: - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - - - name: Release organization Unity lock - id: release_unity_lock - if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - lock-name: wallstop-organization-builds - holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} - runner-id: ${{ runner.name }} - resource-cleanup-status: ${{ steps.return_unity_license.outputs.resource-cleanup-status }} - resource-health: ${{ steps.return_unity_license.outputs.resource-health }} - resource-reason: ${{ steps.return_unity_license.outputs.resource-reason }} - env: - BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} - BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - - - name: Require confirmed Unity cleanup - if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - acquired: ${{ steps.acquire_lock.outputs.acquired }} - classification-complete: ${{ steps.return_unity_license.outputs.classification-complete }} - cleanup-status: ${{ steps.return_unity_license.outputs.resource-cleanup-status }} - cleanup-health: ${{ steps.return_unity_license.outputs.resource-health }} - cleanup-reason: ${{ steps.return_unity_license.outputs.resource-reason }} - release-outcome: ${{ steps.release_unity_lock.outcome }} - cleanup-result: ${{ steps.release_unity_lock.outputs.cleanup-result }} - released: ${{ steps.release_unity_lock.outputs.released }} - release-health: ${{ steps.release_unity_lock.outputs.resource-health }} - release-reason: ${{ steps.release_unity_lock.outputs.resource-reason }} - reservation-state: ${{ steps.release_unity_lock.outputs.reservation-state }} - reservation-id: ${{ steps.release_unity_lock.outputs.reservation-id }} - incident-id: ${{ steps.release_unity_lock.outputs.incident-id }} - - - name: Delete private Unity cleanup evidence - if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} - shell: pwsh - env: - RETURN_LOG_PATH: ${{ steps.return_unity_license.outputs.return-log-path }} - SUPPLEMENTAL_EVIDENCE_PATHS: ${{ steps.return_unity_license.outputs.supplemental-evidence-paths }} - run: | - $evidencePaths = @($env:RETURN_LOG_PATH) + @($env:SUPPLEMENTAL_EVIDENCE_PATHS -split "[`r`n]+") - foreach ($evidencePath in @($evidencePaths | Sort-Object -Unique)) { - if (-not [string]::IsNullOrWhiteSpace($evidencePath) -and (Test-Path -LiteralPath $evidencePath -PathType Leaf)) { - Remove-Item -LiteralPath $evidencePath -Force - } - } - # CANCELLATION + TIMEOUT DIAGNOSTIC: when Unity 2022 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 @@ -349,3 +241,75 @@ jobs: path: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }} if-no-files-found: warn retention-days: 90 + + - name: Return Unity license + id: return_unity_license + if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} + timeout-minutes: 5 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + unity-version: ${{ matrix.unity-version }} + tool-cache: ${{ runner.tool_cache }} + unity-email: ${{ secrets.UNITY_EMAIL }} + unity-password: ${{ secrets.UNITY_PASSWORD }} + evidence-suffix: benchmarks + + - name: Classify Unity cleanup evidence + id: cleanup_classification + if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} + timeout-minutes: 2 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + return-log-path: ${{ steps.return_unity_license.outputs.return-log-path }} + return-command-completed: ${{ steps.return_unity_license.outputs.return-command-completed }} + return-exit-code: ${{ steps.return_unity_license.outputs.return-exit-code }} + evidence-capture-complete: ${{ steps.return_unity_license.outputs.evidence-capture-complete }} + return-log-digest: ${{ steps.return_unity_license.outputs.return-log-digest }} + + - name: Release organization Unity lock + id: release_unity_lock + if: always() + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + lock-name: wallstop-organization-builds + holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} + runner-id: ${{ runner.name }} + resource-cleanup-status: ${{ steps.cleanup_classification.outputs.resource-cleanup-status }} + resource-health: ${{ steps.cleanup_classification.outputs.resource-health }} + resource-reason: ${{ steps.cleanup_classification.outputs.resource-reason }} + env: + BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} + BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} + + - name: Require confirmed Unity cleanup + if: always() + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + acquired: ${{ steps.acquire_lock.outputs.acquired }} + classification-complete: ${{ steps.cleanup_classification.outputs.classification-complete }} + cleanup-status: ${{ steps.cleanup_classification.outputs.resource-cleanup-status }} + cleanup-health: ${{ steps.cleanup_classification.outputs.resource-health }} + cleanup-reason: ${{ steps.cleanup_classification.outputs.resource-reason }} + release-outcome: ${{ steps.release_unity_lock.outcome }} + cleanup-result: ${{ steps.release_unity_lock.outputs.cleanup-result }} + released: ${{ steps.release_unity_lock.outputs.released }} + release-health: ${{ steps.release_unity_lock.outputs.resource-health }} + release-reason: ${{ steps.release_unity_lock.outputs.resource-reason }} + reservation-state: ${{ steps.release_unity_lock.outputs.reservation-state }} + reservation-id: ${{ steps.release_unity_lock.outputs.reservation-id }} + incident-id: ${{ steps.release_unity_lock.outputs.incident-id }} + + benchmark-unity-success: + name: Benchmark Unity Success + if: always() + needs: + - runner-preflight + - benchmarks + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require complete benchmark validation + shell: bash + run: | + test "${{ needs.runner-preflight.result }}" = success + test "${{ needs.benchmarks.result }}" = success diff --git a/.github/workflows/unity-gameci-experiment.yml b/.github/workflows/unity-gameci-experiment.yml deleted file mode 100644 index 0ae8ee16..00000000 --- a/.github/workflows/unity-gameci-experiment.yml +++ /dev/null @@ -1,266 +0,0 @@ -name: Unity GameCI Experiment - -on: - workflow_dispatch: - inputs: - unity-version: - description: "Unity version to test through GameCI normal project mode." - required: false - default: "2022.3.45f1" - type: string - test-mode: - description: "Test mode to run through GameCI normal project mode." - required: false - default: "editmode" - type: choice - options: - - editmode - - playmode - - standalone - -permissions: - contents: read - checks: write - -jobs: - runner-preflight: - name: Self-hosted runner access preflight - runs-on: ubuntu-latest - timeout-minutes: 3 - steps: - - name: Require an online Unity runner - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/check-unity-runner-availability@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - reader-app-id: ${{ secrets.BUILD_LOCK_READER_APP_ID }} - reader-app-private-key: ${{ secrets.BUILD_LOCK_READER_APP_PRIVATE_KEY }} - required-label-sets: '[["self-hosted","Windows","RAM-64GB"]]' - - game-ci-experiment: - name: GameCI ${{ inputs.unity-version }} ${{ inputs.test-mode }} - needs: runner-preflight - runs-on: [self-hosted, Windows, RAM-64GB] - timeout-minutes: 660 - continue-on-error: true - steps: - - name: Enable Git long paths - shell: pwsh - env: - GIT_CONFIG_GLOBAL: ${{ runner.temp }}/dxm-gitconfig - run: git config --global core.longpaths true - - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - env: - GIT_CONFIG_GLOBAL: ${{ runner.temp }}/dxm-gitconfig - with: - lfs: true - persist-credentials: false - - - name: Print runner diagnostics - uses: ./.github/actions/print-self-hosted-runner-diagnostics - with: - matrix-note: "GameCI compatibility experiment (non-required)" - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "22.18.0" - - - name: Compute test assembly list - id: compute - uses: ./.github/actions/compute-unity-assemblies - with: - target: "${{ inputs.test-mode }}" - runtime-only: "${{ inputs.test-mode == 'standalone' && 'true' || 'false' }}" - - # Skip provisioning, the ephemeral-project generation, the org lock, and - # the GameCI run when the compute step resolved an empty assembly list, so - # an empty discovery never takes the paid Unity seat / org lock only to - # fail late at verify. Mirrors unity-tests.yml. The list is structurally - # non-empty for the supported inputs, 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/game-ci/${{ inputs.unity-version }}-${{ inputs.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 ('${{ inputs.test-mode }}' -eq 'standalone') { - 'StandaloneWindowsIl2Cpp' - } else { - 'EditorOnly' - } - $editor = ./scripts/unity/ensure-editor.ps1 ` - -UnityVersion '${{ inputs.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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: unity-game-ci-provisioning-${{ inputs.unity-version }}-${{ inputs.test-mode }} - path: .artifacts/unity/game-ci/${{ inputs.unity-version }}-${{ inputs.test-mode }}/provisioning - if-no-files-found: warn - retention-days: 14 - - - name: Generate ephemeral Unity project - if: ${{ steps.compute.outputs.is-empty != 'true' }} - shell: pwsh - run: | - ./scripts/unity/run-ci-tests.ps1 ` - -UnityVersion '${{ inputs.unity-version }}' ` - -TestMode '${{ inputs.test-mode }}' ` - -AssemblyNames $env:DXM_TEST_ASSEMBLIES ` - -ArtifactsPath '.artifacts/unity/game-ci/${{ inputs.unity-version }}-${{ inputs.test-mode }}' ` - -ProjectPath '.artifacts/unity/game-ci-projects/${{ inputs.unity-version }}-${{ inputs.test-mode }}' ` - -ReleaseCodeOptimization ` - -ReleasePlayerBuild ` - -GenerateOnly - - - 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: Acquire organization Unity lock - id: acquire_lock - if: ${{ steps.compute.outputs.is-empty != 'true' }} - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - lock-name: wallstop-organization-builds - holder-id-suffix: game-ci-experiment - runner-id: ${{ runner.name }} - timeout-minutes: "300" - env: - BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} - BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - - - name: Require acquired Unity lock - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired != 'true' }} - shell: pwsh - run: exit 1 - - - name: Run GameCI normal project mode - id: game_ci - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} - timeout-minutes: 120 - # game-ci reads UNITY_SERIAL/UNITY_EMAIL/UNITY_PASSWORD from env for - # classic serial activation (the unityLicensingServer input is retired - # now that this repo moved off the floating-license server). - env: - UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - uses: game-ci/unity-test-runner@0ff419b913a3630032cbe0de48a0099b5a9f0ed9 # v4 - with: - packageMode: false - projectPath: .artifacts/unity/game-ci-projects/${{ inputs.unity-version }}-${{ inputs.test-mode }} - unityVersion: ${{ inputs.unity-version }} - testMode: ${{ inputs.test-mode }} - customParameters: -nographics -releaseCodeOptimization -assemblyNames "${{ env.DXM_TEST_ASSEMBLIES }}" - artifactsPath: .artifacts/unity/game-ci/${{ inputs.unity-version }}-${{ inputs.test-mode }} - checkName: GameCI experiment ${{ inputs.unity-version }} ${{ inputs.test-mode }} - githubToken: ${{ secrets.GITHUB_TOKEN }} - - # GameCI attempts its own best-effort return-license. This independent - # backstop uses the editor path exported by Provision Unity Editor above, - # so it reports safe only when that local return succeeds (or proves the - # activation already absent); otherwise lifecycle release quarantines the - # runner's slot for same-runner recovery. - - name: Return Unity license - id: return_unity_license - if: always() - timeout-minutes: 5 - continue-on-error: true - uses: ./.github/actions/return-unity-license - with: - evidence-paths: .artifacts/unity/game-ci/${{ inputs.unity-version }}-${{ inputs.test-mode }} - env: - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - - - name: Release organization Unity lock - id: release_unity_lock - if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - lock-name: wallstop-organization-builds - holder-id-suffix: game-ci-experiment - runner-id: ${{ runner.name }} - resource-cleanup-status: ${{ steps.return_unity_license.outputs.resource-cleanup-status }} - resource-health: ${{ steps.return_unity_license.outputs.resource-health }} - resource-reason: ${{ steps.return_unity_license.outputs.resource-reason }} - env: - BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} - BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - - - name: Require confirmed Unity cleanup - if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - acquired: ${{ steps.acquire_lock.outputs.acquired }} - classification-complete: ${{ steps.return_unity_license.outputs.classification-complete }} - cleanup-status: ${{ steps.return_unity_license.outputs.resource-cleanup-status }} - cleanup-health: ${{ steps.return_unity_license.outputs.resource-health }} - cleanup-reason: ${{ steps.return_unity_license.outputs.resource-reason }} - release-outcome: ${{ steps.release_unity_lock.outcome }} - cleanup-result: ${{ steps.release_unity_lock.outputs.cleanup-result }} - released: ${{ steps.release_unity_lock.outputs.released }} - release-health: ${{ steps.release_unity_lock.outputs.resource-health }} - release-reason: ${{ steps.release_unity_lock.outputs.resource-reason }} - reservation-state: ${{ steps.release_unity_lock.outputs.reservation-state }} - reservation-id: ${{ steps.release_unity_lock.outputs.reservation-id }} - incident-id: ${{ steps.release_unity_lock.outputs.incident-id }} - - - name: Delete private Unity cleanup evidence - if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} - shell: pwsh - env: - RETURN_LOG_PATH: ${{ steps.return_unity_license.outputs.return-log-path }} - SUPPLEMENTAL_EVIDENCE_PATHS: ${{ steps.return_unity_license.outputs.supplemental-evidence-paths }} - run: | - $evidencePaths = @($env:RETURN_LOG_PATH) + @($env:SUPPLEMENTAL_EVIDENCE_PATHS -split "[`r`n]+") - foreach ($evidencePath in @($evidencePaths | Sort-Object -Unique)) { - if (-not [string]::IsNullOrWhiteSpace($evidencePath) -and (Test-Path -LiteralPath $evidencePath -PathType Leaf)) { - Remove-Item -LiteralPath $evidencePath -Force - } - } - - - name: Dump Unity log tail on failure or cancellation - if: ${{ failure() || cancelled() }} - uses: ./.github/actions/dump-unity-log-tail - with: - results-dir: .artifacts/unity/game-ci/${{ inputs.unity-version }}-${{ inputs.test-mode }} - label: GameCI experiment ${{ inputs.unity-version }} ${{ inputs.test-mode }} - - - name: Verify tests actually ran - if: >- - ${{ - !cancelled() && - steps.compute.outcome == 'success' && - (steps.compute.outputs.is-empty == 'true' || steps.game_ci.outcome != 'skipped') - }} - uses: ./.github/actions/verify-unity-results - with: - results-dir: .artifacts/unity/game-ci/${{ inputs.unity-version }}-${{ inputs.test-mode }} - label: GameCI experiment ${{ inputs.unity-version }} ${{ inputs.test-mode }} - expected-empty: ${{ steps.compute.outputs.is-empty }} - - - name: Upload GameCI experiment artifacts - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: unity-game-ci-experiment-${{ inputs.unity-version }}-${{ inputs.test-mode }} - path: | - .artifacts/unity/game-ci/ - .artifacts/unity/game-ci-projects/ - if-no-files-found: warn - retention-days: 14 diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 54688e10..298e313d 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -4,33 +4,13 @@ on: pull_request: branches: - master - - main push: branches: - master - - main paths-ignore: - "docs/architecture/performance.md" - "docs/architecture/perf-baseline.csv" - 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 @@ -42,93 +22,6 @@ concurrency: cancel-in-progress: false jobs: - matrix-config: - name: Resolve Unity test matrix - runs-on: ubuntu-latest - timeout-minutes: 2 - # Job-level permissions REPLACE the top-level set: contents:read for the - # checkout, pull-requests:read for the CI-owned-docs-only file listing. - permissions: - contents: read - pull-requests: read - outputs: - unity-versions: ${{ steps.resolve.outputs.unity-versions }} - test-modes: ${{ steps.resolve.outputs.test-modes }} - ci-owned-docs-only: ${{ steps.docs-only.outputs.docs-only }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - persist-credentials: false - - - name: Detect CI-owned docs-only pull request - id: docs-only - # The perf-numbers workflow owns docs/architecture/performance.md and - # docs/architecture/perf-baseline.csv end to end and delivers them via a - # fallback auto-merge PR when its direct push is rejected. A PR whose - # ENTIRE diff is those two files has nothing for the licensed Unity - # matrix to test, and skipping the matrix keeps the fallback PR cheap. - # Branch protection requires the always-reporting `Unity CI Success` - # aggregate, not these matrix legs; a skipped pre-expansion matrix job - # can report only the literal unevaluated matrix name. CONTRACT mirror - # of runner-preflight: detection failures must never make CI more - # broken than the no-detection baseline, so any API error soft-fails to - # docs-only=false (full matrix runs). - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - if [ "${GITHUB_EVENT_NAME}" != "pull_request" ] || [ -z "${PR_NUMBER:-}" ]; then - echo "docs-only=false" >> "${GITHUB_OUTPUT}" - exit 0 - fi - files_list="$(mktemp)" - if ! gh api --paginate \ - "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \ - --jq '.[].filename' > "${files_list}" 2>/dev/null; then - echo "::warning::Could not list PR files; assuming a full code diff (matrix runs)." - echo "docs-only=false" >> "${GITHUB_OUTPUT}" - exit 0 - fi - if [ ! -s "${files_list}" ]; then - echo "docs-only=false" >> "${GITHUB_OUTPUT}" - exit 0 - fi - docs_only=true - while IFS= read -r changed; do - case "${changed}" in - docs/architecture/performance.md|docs/architecture/perf-baseline.csv) ;; - *) - docs_only=false - break - ;; - esac - done < "${files_list}" - echo "CI-owned docs-only PR: ${docs_only}" - echo "docs-only=${docs_only}" >> "${GITHUB_OUTPUT}" - - 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}" - runner-preflight: name: Self-hosted runner access preflight # Runs on GitHub-hosted infrastructure before any self-hosted matrix entry @@ -143,10 +36,9 @@ jobs: # validated by the full matrix on the post-merge push to master. if: >- ${{ - (github.event_name != 'pull_request' || - (github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.user.login != 'dependabot[bot]')) && - (github.event_name != 'push' || github.ref_protected) + github.event_name != 'pull_request' || + (github.actor != 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: ubuntu-latest timeout-minutes: 3 @@ -164,7 +56,6 @@ jobs: unity-tests: name: Unity ${{ matrix.unity-version }} ${{ matrix.test-mode }} needs: - - matrix-config - runner-preflight # Same-repository pull requests run licensed validation with organization # secrets and no environment approval. Fork pull requests and Dependabot @@ -173,11 +64,9 @@ jobs: # credentials rather than on the change under test. if: >- ${{ - (github.event_name != 'pull_request' || - (github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.user.login != 'dependabot[bot]')) && - (github.event_name != 'push' || github.ref_protected) && - needs.matrix-config.outputs.ci-owned-docs-only != 'true' + github.event_name != 'pull_request' || + (github.actor != 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: [self-hosted, Windows, RAM-64GB] timeout-minutes: 660 @@ -185,8 +74,14 @@ jobs: 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) }} + unity-version: + - 2021.3.45f1 + - 2022.3.45f1 + - 6000.3.16f1 + test-mode: + - editmode + - playmode + - standalone steps: - name: Require current PR head before setup uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-current-pr-head@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 @@ -311,7 +206,6 @@ jobs: - name: Acquire organization Unity lock id: acquire_lock - if: ${{ steps.compute.outputs.is-empty != 'true' }} uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: lock-name: wallstop-organization-builds @@ -326,7 +220,7 @@ jobs: BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - name: Require acquired Unity lock - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired != 'true' }} + if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} shell: pwsh run: exit 1 @@ -359,69 +253,6 @@ jobs: -ReleaseCodeOptimization ` -ReleasePlayerBuild - - name: Return Unity license - id: return_unity_license - if: always() - timeout-minutes: 5 - continue-on-error: true - uses: ./.github/actions/return-unity-license - with: - prior-return-log-path: ${{ runner.temp }}/unity-return-${{ matrix.unity-version }}-${{ matrix.test-mode }}.log - prior-command-succeeded: ${{ steps.run_tests.outcome == 'success' }} - evidence-paths: | - ${{ runner.temp }}/unity-activate-${{ matrix.unity-version }}-${{ matrix.test-mode }}.log - .artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }} - env: - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - - - name: Release organization Unity lock - id: release_unity_lock - if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - lock-name: wallstop-organization-builds - holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} - runner-id: ${{ runner.name }} - resource-cleanup-status: ${{ steps.return_unity_license.outputs.resource-cleanup-status }} - resource-health: ${{ steps.return_unity_license.outputs.resource-health }} - resource-reason: ${{ steps.return_unity_license.outputs.resource-reason }} - env: - BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} - BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - - - name: Require confirmed Unity cleanup - if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - acquired: ${{ steps.acquire_lock.outputs.acquired }} - classification-complete: ${{ steps.return_unity_license.outputs.classification-complete }} - cleanup-status: ${{ steps.return_unity_license.outputs.resource-cleanup-status }} - cleanup-health: ${{ steps.return_unity_license.outputs.resource-health }} - cleanup-reason: ${{ steps.return_unity_license.outputs.resource-reason }} - release-outcome: ${{ steps.release_unity_lock.outcome }} - cleanup-result: ${{ steps.release_unity_lock.outputs.cleanup-result }} - released: ${{ steps.release_unity_lock.outputs.released }} - release-health: ${{ steps.release_unity_lock.outputs.resource-health }} - release-reason: ${{ steps.release_unity_lock.outputs.resource-reason }} - reservation-state: ${{ steps.release_unity_lock.outputs.reservation-state }} - reservation-id: ${{ steps.release_unity_lock.outputs.reservation-id }} - incident-id: ${{ steps.release_unity_lock.outputs.incident-id }} - - - name: Delete private Unity cleanup evidence - if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} - shell: pwsh - env: - RETURN_LOG_PATH: ${{ steps.return_unity_license.outputs.return-log-path }} - SUPPLEMENTAL_EVIDENCE_PATHS: ${{ steps.return_unity_license.outputs.supplemental-evidence-paths }} - run: | - $evidencePaths = @($env:RETURN_LOG_PATH) + @($env:SUPPLEMENTAL_EVIDENCE_PATHS -split "[`r`n]+") - foreach ($evidencePath in @($evidencePaths | Sort-Object -Unique)) { - if (-not [string]::IsNullOrWhiteSpace($evidencePath) -and (Test-Path -LiteralPath $evidencePath -PathType Leaf)) { - Remove-Item -LiteralPath $evidencePath -Force - } - } - # CANCELLATION + TIMEOUT DIAGNOSTIC: when Unity 2022 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 @@ -470,63 +301,94 @@ jobs: if-no-files-found: warn retention-days: 14 + - name: Return Unity license + id: return_unity_license + if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} + timeout-minutes: 5 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + unity-version: ${{ matrix.unity-version }} + tool-cache: ${{ runner.tool_cache }} + unity-email: ${{ secrets.UNITY_EMAIL }} + unity-password: ${{ secrets.UNITY_PASSWORD }} + evidence-suffix: unity-tests + + - name: Classify Unity cleanup evidence + id: cleanup_classification + if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} + timeout-minutes: 2 + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + return-log-path: ${{ steps.return_unity_license.outputs.return-log-path }} + return-command-completed: ${{ steps.return_unity_license.outputs.return-command-completed }} + return-exit-code: ${{ steps.return_unity_license.outputs.return-exit-code }} + evidence-capture-complete: ${{ steps.return_unity_license.outputs.evidence-capture-complete }} + return-log-digest: ${{ steps.return_unity_license.outputs.return-log-digest }} + + - name: Release organization Unity lock + id: release_unity_lock + if: always() + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + lock-name: wallstop-organization-builds + holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} + runner-id: ${{ runner.name }} + resource-cleanup-status: ${{ steps.cleanup_classification.outputs.resource-cleanup-status }} + resource-health: ${{ steps.cleanup_classification.outputs.resource-health }} + resource-reason: ${{ steps.cleanup_classification.outputs.resource-reason }} + env: + BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} + BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} + + - name: Require confirmed Unity cleanup + if: always() + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@08fc83e83fa4cae89c0177005b388585ffdb1d9a + with: + acquired: ${{ steps.acquire_lock.outputs.acquired }} + classification-complete: ${{ steps.cleanup_classification.outputs.classification-complete }} + cleanup-status: ${{ steps.cleanup_classification.outputs.resource-cleanup-status }} + cleanup-health: ${{ steps.cleanup_classification.outputs.resource-health }} + cleanup-reason: ${{ steps.cleanup_classification.outputs.resource-reason }} + release-outcome: ${{ steps.release_unity_lock.outcome }} + cleanup-result: ${{ steps.release_unity_lock.outputs.cleanup-result }} + released: ${{ steps.release_unity_lock.outputs.released }} + release-health: ${{ steps.release_unity_lock.outputs.resource-health }} + release-reason: ${{ steps.release_unity_lock.outputs.resource-reason }} + reservation-state: ${{ steps.release_unity_lock.outputs.reservation-state }} + reservation-id: ${{ steps.release_unity_lock.outputs.reservation-id }} + incident-id: ${{ steps.release_unity_lock.outputs.incident-id }} + # Single aggregate gate so branch protection can require ONE "Unity CI Success" - # context instead of the 11 individual Unity contexts (the matrix legs + - # matrix-config + runner-preflight). See docs/runbooks/required-checks.md. + # context instead of the individual matrix and preflight contexts. unity-ci-success: name: Unity CI Success needs: - - matrix-config - runner-preflight - unity-tests # Exactly always() -- NEVER a falsifiable job-level if. A skipped required # check counts as passing and the latest run on a SHA wins, so a guard that # can be false on a pull_request could launder a red/pending gate into a - # false green. The step below validates the only three intentional skip + # false green. The step below validates the only two intentional skip # shapes: fork PRs and Dependabot PRs skip both licensed jobs (neither can - # read the organization secrets those jobs require), while CI-owned - # docs-only PRs run preflight but skip the Unity matrix. Every other run - # must execute Unity successfully. + # read the organization secrets those jobs require). Every other run must + # execute Unity successfully. if: ${{ always() }} runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Verify Unity CI result shape + shell: bash env: - MATRIX_CONFIG_RESULT: ${{ needs.matrix-config.result }} RUNNER_PREFLIGHT_RESULT: ${{ needs.runner-preflight.result }} UNITY_TESTS_RESULT: ${{ needs.unity-tests.result }} FORK_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} - DEPENDABOT_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dependabot[bot]' }} - DOCS_ONLY: ${{ needs.matrix-config.outputs.ci-owned-docs-only }} + DEPENDABOT_PR: ${{ github.event_name == 'pull_request' && github.actor == 'dependabot[bot]' }} run: | set -euo pipefail - failed=0 - - assert_result() { - local variable="$1" - local expected="$2" - local actual="$3" - echo "${variable}=${actual} (expected ${expected})" - if [ "${actual}" != "${expected}" ]; then - failed=1 - fi - } - - if [ "${MATRIX_CONFIG_RESULT}" != "success" ]; then - echo "MATRIX_CONFIG_RESULT=${MATRIX_CONFIG_RESULT} (expected success)" - failed=1 - fi - if [ "${FORK_PR}" = "true" ] || [ "${DEPENDABOT_PR}" = "true" ]; then - assert_result RUNNER_PREFLIGHT_RESULT skipped "${RUNNER_PREFLIGHT_RESULT}" - assert_result UNITY_TESTS_RESULT skipped "${UNITY_TESTS_RESULT}" - elif [ "${DOCS_ONLY}" = "true" ]; then - assert_result RUNNER_PREFLIGHT_RESULT success "${RUNNER_PREFLIGHT_RESULT}" - assert_result UNITY_TESTS_RESULT skipped "${UNITY_TESTS_RESULT}" + test "${RUNNER_PREFLIGHT_RESULT}" = skipped + test "${UNITY_TESTS_RESULT}" = skipped else - assert_result RUNNER_PREFLIGHT_RESULT success "${RUNNER_PREFLIGHT_RESULT}" - assert_result UNITY_TESTS_RESULT success "${UNITY_TESTS_RESULT}" + test "${RUNNER_PREFLIGHT_RESULT}" = success + test "${UNITY_TESTS_RESULT}" = success fi - - exit "${failed}" diff --git a/.llm/index.json b/.llm/index.json index 8b526d34..832f6370 100644 --- a/.llm/index.json +++ b/.llm/index.json @@ -208,7 +208,7 @@ "category": "performance", "tags": "performance, benchmarks, il2cpp, release, ci, standalone" }, - "lineCount": 116, + "lineCount": 117, "references": [ ".llm/skills/il2cpp-build-configuration/references/mono-vs-il2cpp-optimization-split.md", ".llm/skills/il2cpp-build-configuration/references/perf-config-il2cpp-release-netstandard21.md" @@ -431,7 +431,7 @@ "category": "unity", "tags": "unity, ci, matrix, il2cpp, lts, game-ci" }, - "lineCount": 122, + "lineCount": 123, "references": [ ".llm/skills/unity-editor-ci/references/github-actions-version-consistency.md", ".llm/skills/unity-editor-ci/references/unity-ci-matrix.md", @@ -463,7 +463,7 @@ "category": "unity", "tags": "unity, serial, license, return, leak, seat, ci" }, - "lineCount": 73, + "lineCount": 77, "references": [ ".llm/skills/unity-licensing/references/unity-license-bootstrap.md", ".llm/skills/unity-licensing/references/unity-license-return-guarantee.md" diff --git a/.llm/skills/benchmark-methodology/references/benchmarks-run-in-highest-fidelity-scope.md b/.llm/skills/benchmark-methodology/references/benchmarks-run-in-highest-fidelity-scope.md index 198b1d39..3a882450 100644 --- a/.llm/skills/benchmark-methodology/references/benchmarks-run-in-highest-fidelity-scope.md +++ b/.llm/skills/benchmark-methodology/references/benchmarks-run-in-highest-fidelity-scope.md @@ -80,9 +80,9 @@ player number. The Performance Numbers workflow (`.github/workflows/perf-numbers.yml`) publishes only the `standalone` leg for this reason: a Release IL2CPP player -is the highest shipping fidelity. The weekly `unity-benchmarks.yml` still runs -the EditMode and PlayMode benchmark tests across Unity versions, as coverage -rather than published numbers. +is the highest shipping fidelity. The manually dispatched +`unity-benchmarks.yml` still runs the EditMode and PlayMode benchmark tests +across Unity versions, as coverage rather than published numbers. ## Common Pitfalls diff --git a/.llm/skills/benchmark-methodology/references/unity-perf-test-isolation.md b/.llm/skills/benchmark-methodology/references/unity-perf-test-isolation.md index c6a99381..35def67a 100644 --- a/.llm/skills/benchmark-methodology/references/unity-perf-test-isolation.md +++ b/.llm/skills/benchmark-methodology/references/unity-perf-test-isolation.md @@ -85,10 +85,10 @@ If the asmdef ends up in the `core` bucket instead, the most common cause is the ## Where Perf Actually Runs -| Workflow | Triggers | Includes Perf? | -| ---------------------- | ------------------------------- | -------------- | -| `unity-tests.yml` | PR / push / schedule / dispatch | NO | -| `unity-benchmarks.yml` | schedule / dispatch | YES | +| Workflow | Triggers | Includes Perf? | +| ---------------------- | ----------------------------------- | -------------- | +| `unity-tests.yml` | PR / default-branch push / dispatch | NO | +| `unity-benchmarks.yml` | dispatch | YES | The active `.github/workflows/unity-*.yml` workflows run Unity directly on self-hosted Windows runners through `scripts/unity/run-ci-tests.ps1` (benchmarks diff --git a/.llm/skills/github-workflow-consistency/SKILL.md b/.llm/skills/github-workflow-consistency/SKILL.md index 5452cff7..2a4e1dc0 100644 --- a/.llm/skills/github-workflow-consistency/SKILL.md +++ b/.llm/skills/github-workflow-consistency/SKILL.md @@ -11,7 +11,7 @@ metadata: Every workflow under `.github/workflows/` follows the same skeleton, declares the least privilege it needs, and fails closed. This skill also covers the four subsystems that most often break a workflow: git commands in CI, lychee link checking, devcontainer image -publishing, and the tag-triggered release. +publishing, and the tag-selected manual release dispatch. ## When to use diff --git a/.llm/skills/github-workflow-consistency/references/release-asset-and-notes-invariants.md b/.llm/skills/github-workflow-consistency/references/release-asset-and-notes-invariants.md index a9bca117..5b480985 100644 --- a/.llm/skills/github-workflow-consistency/references/release-asset-and-notes-invariants.md +++ b/.llm/skills/github-workflow-consistency/references/release-asset-and-notes-invariants.md @@ -1,6 +1,6 @@ ## Overview -The tag-triggered release pipeline (`release.yml`) ships two artifacts to each +The tag-selected manual release pipeline (`release.yml`) ships two artifacts to each GitHub Release: the npm tarball and a `.unitypackage`, with a body drawn from `CHANGELOG.md`. Three invariants keep that release correct and complete. All three regressed at once in `v3.1.0` (run `75151961234`): the release body was a diff --git a/.llm/skills/il2cpp-build-configuration/SKILL.md b/.llm/skills/il2cpp-build-configuration/SKILL.md index d6d6a198..87961317 100644 --- a/.llm/skills/il2cpp-build-configuration/SKILL.md +++ b/.llm/skills/il2cpp-build-configuration/SKILL.md @@ -71,8 +71,9 @@ plus `-ReleasePlayerBuild` and `-ReleaseCodeOptimization`; Standalone tests take `Standalone IL2CPP x64 Release (WindowsPlayer; ...)`. A published `x64 Debug` row means `Debug.isDebugBuild` was true - a configuration bug, not a code regression. -- Never publish a Debug or in-editor number. PlayMode and EditMode still run the - same scenarios (weekly `unity-benchmarks.yml`) as coverage, not as numbers. +- Never publish a Debug number. PlayMode supplies allocation evidence in + `perf-numbers.yml`; manually dispatched `unity-benchmarks.yml` supplies + per-version editor coverage. ### Backend split diff --git a/.llm/skills/il2cpp-build-configuration/references/perf-config-il2cpp-release-netstandard21.md b/.llm/skills/il2cpp-build-configuration/references/perf-config-il2cpp-release-netstandard21.md index 0226200e..2dddba15 100644 --- a/.llm/skills/il2cpp-build-configuration/references/perf-config-il2cpp-release-netstandard21.md +++ b/.llm/skills/il2cpp-build-configuration/references/perf-config-il2cpp-release-netstandard21.md @@ -15,10 +15,10 @@ CI from a single runner, `scripts/unity/run-ci-tests.ps1`. One leg is published. The headline is a Standalone player built under IL2CPP, the ahead-of-time (AOT) backend shipped players actually run, with the .NET Standard 2.1 API surface and every Release knob engaged. The in-editor -PlayMode Mono leg is retired from publishing; PlayMode and EditMode still run -the same scenarios for local iteration and for the weekly per-version -benchmark-test coverage in `unity-benchmarks.yml`, but those runs are -coverage, not published numbers. +PlayMode Mono supplies allocation counts and bytes alongside the Standalone +throughput headline. PlayMode and EditMode also run the same scenarios for +local iteration and for manually dispatched per-version benchmark-test +coverage in `unity-benchmarks.yml`. ## Problem Statement @@ -117,12 +117,14 @@ Two artifacts prove a published run used the right profile: ## CI Wiring The Performance Numbers workflow (`.github/workflows/perf-numbers.yml`) runs a -single-entry matrix: the `standalone` leg with comparisons enabled and -`StandaloneScriptingBackend = 'IL2CPP'` on top of `ReleasePlayerBuild` and -`ReleaseCodeOptimization`. The regression gate (`render-perf-deltas.js`) -compares `--scope Standalone` rows against the committed master baseline. The -weekly `unity-benchmarks.yml` runs the EditMode and PlayMode benchmark tests -across Unity versions with `-ReleaseCodeOptimization` for coverage only. +two-cell static matrix: the `standalone` leg supplies comparison throughput +with `StandaloneScriptingBackend = 'IL2CPP'` on top of `ReleasePlayerBuild` and +`ReleaseCodeOptimization`, while PlayMode supplies allocation evidence. The +regression gate (`render-perf-deltas.js`) compares `--scope Standalone` rows +against the committed master baseline. The +manually dispatched `unity-benchmarks.yml` runs the EditMode and PlayMode +benchmark tests across Unity versions with `-ReleaseCodeOptimization` for +coverage only. ## Common Pitfalls diff --git a/.llm/skills/unity-editor-ci/SKILL.md b/.llm/skills/unity-editor-ci/SKILL.md index df73488f..57607c92 100644 --- a/.llm/skills/unity-editor-ci/SKILL.md +++ b/.llm/skills/unity-editor-ci/SKILL.md @@ -31,12 +31,12 @@ Windows runners. `unity-tests.yml` is one unified matrix of three Unity versions strictly ascending by `major.minor.patch`) and `release` (must be a member of `all`). `latest` is DEFINED as the last element of `all` and is never stored as its own key. - Bump versions ONLY in that file, then run `npm run validate:unity-versions`. -- `scripts/validate-unity-versions.js` assigns each consumer one policy: `no-literals` (reads - the JSON at runtime via `jq`; the default for every active workflow, including - `unity-tests.yml`, `unity-benchmarks.yml`, `perf-numbers.yml`), `mirror-all` (literal set - must equal `all`: `runner-bootstrap.yml`, `scripts/unity/maintain-windows-runner.ps1`, - `scripts/unity/install-runner-maintenance-task.ps1`), and `mirror-release` (every literal - equals `release`: `release.yml`, `unity-gameci-experiment.yml`). +- `scripts/validate-unity-versions.js` assigns each consumer one policy: + `no-literals` (the default for unregistered workflows), `mirror-all` + (`unity-tests.yml`, `unity-benchmarks.yml`, runner bootstrap and maintenance + scripts), `mirror-latest` (`perf-numbers.yml`), and `mirror-release` + (`release.yml`). Licensed workflow matrices stay literal and static so the + organization lock analyzer can attest every matrix identity. - `.github/workflows-disabled/` and the canonical file itself are excluded from scanning. `ci.yml` runs the validator in the `Lint GitHub Actions workflows` job, so drift blocks merge. @@ -58,9 +58,10 @@ Windows runners. `unity-tests.yml` is one unified matrix of three Unity versions ### The compute-unity-assemblies is-empty gate -- The compute step carries `id: compute`. Every license-consuming step in the same job - (`ensure-editor.ps1` provision, `acquire-build-lock`, `run-ci-tests.ps1`) is gated with - `if: ${{ steps.compute.outputs.is-empty != 'true' }}`. +- The compute step carries `id: compute`. Provisioning and the Unity work step + may skip an empty assembly selection, but lock acquisition remains + unconditional because each static matrix is structurally non-empty and the + analyzer must be able to prove every acquisition. - `Verify tests actually ran` must require `steps.compute.outcome == 'success'` plus either `is-empty == 'true'` or a non-skipped Unity run step, and receives `expected-empty: ${{ steps.compute.outputs.is-empty }}`. Never gate verify on is-empty alone. diff --git a/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md b/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md index a5e19c86..18eaa711 100644 --- a/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md +++ b/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md @@ -25,9 +25,18 @@ | `unity-version` | `2021.3.45f1`, `2022.3.45f1`, `6000.3.16f1` | | `test-mode` | `editmode`, `playmode`, `standalone` | -Nine matrix cells. `editmode`/`playmode` run in-editor on Mono; `standalone` builds and runs a `StandaloneWindows64` IL2CPP player. The direct runner generates a temporary package host project under `.artifacts/unity/projects/-/`, imports the repo package with a `file:` dependency, sets `testables`, and configures IL2CPP before running standalone tests. Workflow_dispatch inputs let you pin a single version or single mode for triage. - -The Unity version list is canonical in `.github/unity-versions.json`; `unity-tests.yml` reads it at runtime and carries no version literal. Bump versions only in that file -- see [Unity Version Single Source of Truth](./unity-version-single-source.md). +Nine matrix cells. `editmode`/`playmode` run in-editor on Mono; `standalone` +builds and runs a `StandaloneWindows64` IL2CPP player. The direct runner +generates a temporary package host project under +`.artifacts/unity/projects/-/`, imports the repo package with a +`file:` dependency, sets `testables`, and configures IL2CPP before running +standalone tests. Dispatch runs use the same complete static matrix. + +The Unity version list is canonical in `.github/unity-versions.json`; +`unity-tests.yml` carries a static literal mirror so the organization analyzer +can attest every matrix identity. Bump the canonical file and every validator +reported mirror together -- see +[Unity Version Single Source of Truth](./unity-version-single-source.md). Licensed Unity execution is serialized by the central `Ambiguous-Interactive/ambiguous-organization-build-lock` actions. The workflows @@ -50,7 +59,7 @@ The Unity serial has two activation seats shared across the organization and no - `max-parallel: 1` only: cannot prevent two separate runs (two pushes, `unity-tests` plus `unity-benchmarks`, or another org repo) from racing for the seat. - The lock only: leaves all 9 cells spawning at once, so idle cells burn their job-timeout clocks, one repository can occupy both seats, and logs become noisy without useful per-run throughput. -With both controls, a run consumes at most one seat while another repository can use the second. This is `max-parallel: 1` ONLY -- it is NOT a native concurrency group. A native `concurrency.group: wallstop-organization-builds` is repository-scoped, serializes whole jobs, and is forbidden. Add `max-parallel: 1` under `strategy:` (sibling of `fail-fast`/`matrix`) on the matrix workflows only (`unity-tests.yml`, `unity-benchmarks.yml`); single-job workflows (`release.unity-checks`, the GameCI experiment) rely on the lock for cross-run admission. +With both controls, a run consumes at most one seat while another repository can use the second. This is `max-parallel: 1` ONLY -- it is NOT a native concurrency group. A native `concurrency.group: wallstop-organization-builds` is repository-scoped, serializes whole jobs, and is forbidden. Add `max-parallel: 1` under `strategy:` (sibling of `fail-fast`/`matrix`) on the matrix workflows (`unity-tests.yml`, `unity-benchmarks.yml`, `perf-numbers.yml`); single-job release workflows rely on the lock for cross-run admission. **Timeout invariant.** GitHub counts the lock-wait against the job clock, so a job at the back of the serialized queue is killed before its lock wait can finish unless: @@ -64,20 +73,18 @@ The step-level `timeout-minutes: 120` protects the in-use seat from a hung edito **Operator note (standalone IL2CPP):** the `standalone` cells require the Windows IL2CPP Unity module and the host build toolchain needed by Unity for Windows players. `scripts/unity/ensure-editor.ps1` must be called with an explicit provisioning profile: `EditorOnly` for editmode/playmode/benchmarks/release checks, and `StandaloneWindowsIl2Cpp` for standalone so only `windows-il2cpp` is installed or verified. That script treats the beta standalone Unity CLI as a moving surface: it discovers the install root through the 0-arg `unity install-path` GETTER and sets the path best-effort, so an uncertain flag never aborts the matrix. See [unity-editor-cli-bootstrap](./unity-editor-cli-bootstrap.md) for the getter-vs-setter detail. -`unity-gameci-experiment.yml` is manual-only and non-required. It generates the same ephemeral project and then calls `game-ci/unity-test-runner@v4` in normal project mode (`packageMode: false`). Do not promote GameCI back to required Windows CI unless the full matrix produces real NUnit XML repeatedly. - -`unity-benchmarks.yml` (active; manual/nightly, NEVER on PRs): +`unity-benchmarks.yml` (active; manual-only, NEVER on PRs): -| Axis | Values | -| --------------- | ---------------------- | -| `unity-version` | `2022.3.45f1` | -| `test-mode` | `editmode`, `playmode` | +| Axis | Values | +| --------------- | ----------------------- | +| `unity-version` | every canonical version | +| `test-mode` | `editmode`, `playmode` | The active `unity-benchmarks.yml` explicitly omits `pull_request` and `push` per the perf isolation rule. ## compute-unity-assemblies is-empty Gate -Every workflow that consumes `./.github/actions/compute-unity-assemblies` mirrors the canonical wiring in `unity-tests.yml`: the compute step carries `id: compute`, and every license-consuming step in the same job is gated with `if: ${{ steps.compute.outputs.is-empty != 'true' }}`. License-consuming means the three steps that spend the paid Unity seat: Provision (`scripts/unity/ensure-editor.ps1`), Acquire organization Unity lock (`acquire-build-lock`), and Run (`scripts/unity/run-ci-tests.ps1`). When asmdef discovery resolves no DxMessaging-owned assemblies for a target, the action sets `is-empty=true` and exits 0 -- a skip, not a failure. Without the gate that empty list still provisions the editor, takes the org lock, and runs Unity before failing late at verify, burning paid license and lock time. +Every workflow that consumes `./.github/actions/compute-unity-assemblies` mirrors the canonical wiring in `unity-tests.yml`: the compute step carries `id: compute`, and provisioning plus Unity work skip when `is-empty == 'true'`. Lock acquisition remains unconditional because the static matrix is structurally non-empty and the organization analyzer must prove each acquisition. When asmdef discovery resolves no owned assemblies, verification treats the empty selection as an intentional skip while the terminal return/classify/release/gate chain still proves cleanup. The `Verify tests actually ran` step keeps a cancellation-safe gate and must also require `steps.compute.outcome == 'success'` plus either `steps.compute.outputs.is-empty == 'true'` or a non-skipped Unity run step (never an is-empty gate alone). It receives `expected-empty: ${{ steps.compute.outputs.is-empty }}`, so an intentional skip reads as success rather than a "tests did not run" failure, while checkout/cache/setup/provisioning/lock failures that prevent Unity from launching are not obscured by a generic missing-results annotation. The skip path does not fire for the current asmdef set; it is the robustness path for a target whose assemblies are all filtered out, such as a runtime-only standalone run when every DxMessaging test asmdef is editor-only. @@ -93,7 +100,12 @@ Add a version to the canonical `.github/unity-versions.json` `all` array when on ## How to Add a Unity Version -1. Edit `.github/unity-versions.json` and append the new tag to the `all` array (keep it strictly ascending). `unity-tests.yml` reads this file at runtime, so the workflow itself needs no edit. Use the `unityci/editor` tag format (e.g., `2024.3.10f1`), then run `npm run validate:unity-versions`. See [Unity Version Single Source of Truth](./unity-version-single-source.md). +1. Edit `.github/unity-versions.json` and append the new tag to the `all` array + (keep it strictly ascending). Update the static matrices reported by + `npm run validate:unity-versions`, including `unity-tests.yml` and + `unity-benchmarks.yml`. Use the Unity tag format (for example, + `2024.3.10f1`). See + [Unity Version Single Source of Truth](./unity-version-single-source.md). 1. Verify the Unity standalone CLI can install the requested version on the self-hosted Windows runner, or that the version already exists under `UNITY_EDITOR_INSTALL_ROOT` / `C:\Unity\Editors` / Unity Hub's install path. @@ -145,7 +157,6 @@ For `standalone` runs, the direct runner first configures the generated project ## References - Unity CLI docs: https://docs.unity.com/en-us/hub/unity-cli -- game-ci docs (experiment only): https://game.ci/docs/ - Unity LTS roadmap: https://unity.com/releases/lts - Unity managed code stripping: https://docs.unity3d.com/Manual/ManagedCodeStripping.html -- Active workflows: `.github/workflows/unity-tests.yml` (direct Unity on self-hosted Windows; editmode/playmode/standalone), `.github/workflows/unity-benchmarks.yml`, `.github/workflows/unity-gameci-experiment.yml` (manual-only experiment); ubuntu reference mirrors: `.github/workflows-disabled/` +- Active workflows: `.github/workflows/unity-tests.yml` (direct Unity on self-hosted Windows; editmode/playmode/standalone), `.github/workflows/unity-benchmarks.yml`, `.github/workflows/perf-numbers.yml`, and `.github/workflows/release.yml`; ubuntu reference mirrors: `.github/workflows-disabled/` diff --git a/.llm/skills/unity-editor-ci/references/unity-version-single-source.md b/.llm/skills/unity-editor-ci/references/unity-version-single-source.md index 51b80a4f..81d6dd03 100644 --- a/.llm/skills/unity-editor-ci/references/unity-version-single-source.md +++ b/.llm/skills/unity-editor-ci/references/unity-version-single-source.md @@ -2,7 +2,10 @@ # Unity Version Single Source of Truth -> **One-line summary**: `.github/unity-versions.json` is the canonical Unity version list for all CI; `scripts/validate-unity-versions.js` (`npm run validate:unity-versions`) fails loud if any workflow or script drifts, so a version bump touches only the JSON. +> **One-line summary**: `.github/unity-versions.json` is the canonical Unity +> version list for all CI; `scripts/validate-unity-versions.js` +> (`npm run validate:unity-versions`) fails loud until every static workflow +> matrix and script mirror matches it. ## When to Use @@ -32,39 +35,34 @@ to be a non-empty array of valid version literals, with no duplicates, strictly ascending by the leading `major.minor.patch` triple (one build per line). - `latest` is DEFINED as the last element of `all`. It is never stored as its own - key. `perf-numbers.yml` and `unity-benchmarks.yml` track this newest version. -- `release` is the version the release pipeline and the GameCI experiment pin. The - validator requires it to be a member of `all`. + key. `perf-numbers.yml` tracks this newest version. +- `release` is the version the release pipeline pins. The validator requires it + to be a member of `all`. ## Why a Split: Read the File vs Validated Mirror -The clean answer would be for every consumer to read the JSON at runtime. Most -cannot. A workflow that runs ubuntu-bash can `jq` the file at runtime; a -self-hosted PowerShell step, a GameCI static input `default:`, and a local -`.ps1` / `.sh` entrypoint default cannot read it the same way. So the contract -splits consumers by capability: +Licensed workflow matrices deliberately do not read the JSON at runtime. Their +literal static values let the organization build-lock analyzer enumerate and +attest every lock identity before execution. Local scripts also need literal +defaults, so the validator keeps all mirrors aligned: -- The bash-matrix resolvers read the file at runtime, carry zero literals, and are - truly DRY. -- The static consumers keep a literal, and the validator keeps that literal honest - with a loud failure on drift. +- `mirror-all` consumers carry the full canonical set. +- `mirror-latest` consumers carry only the newest canonical entry. +- `mirror-release` consumers carry the selected release entry. -The result: bump versions only in `.github/unity-versions.json`, and the -validator tells you precisely which mirror, if any, went stale. +The result: start a bump in `.github/unity-versions.json`, then update exactly +the static mirrors the validator reports. -## The Three Consumer Policies +## The Four Consumer Policies `scripts/validate-unity-versions.js` assigns each file one policy. -- `no-literals`: the file must contain zero Unity version literal in code, because - it reads the canonical file at runtime via `jq`. - - `.github/workflows/perf-numbers.yml` - - `.github/workflows/unity-tests.yml` - - `.github/workflows/unity-benchmarks.yml` - - This is also the DEFAULT for every other active `.github/workflows/*.yml`, so - a new workflow that hardcodes a version is caught with no extra wiring. +- `no-literals`: the file must contain zero Unity version literals in code. This + is the default for active workflows not explicitly registered. - `mirror-all`: the set of code literals must equal `all` exactly. + - `.github/workflows/unity-tests.yml` + - `.github/workflows/unity-benchmarks.yml` - `.github/workflows/runner-bootstrap.yml` - `scripts/unity/maintain-windows-runner.ps1` - `scripts/unity/install-runner-maintenance-task.ps1` @@ -72,7 +70,9 @@ validator tells you precisely which mirror, if any, went stale. - `mirror-release`: every code literal must equal `release`, and there must be at least one. - `.github/workflows/release.yml` - - `.github/workflows/unity-gameci-experiment.yml` + +- `mirror-latest`: every code literal must equal the last `all` entry. + - `.github/workflows/perf-numbers.yml` Excluded from scanning: the canonical file itself, and everything under `.github/workflows-disabled/` (an intentionally unchecked archive). The @@ -80,18 +80,16 @@ validator strips inline `#` comments before scanning, so a version mentioned in a comment does not count as a code literal. -## The perf-numbers.yml Fix +## Static Workflow Mirrors -`perf-numbers.yml` previously hardcoded the latest version in three places. The -`runner-preflight` job now resolves the latest version from the canonical file -and exposes `latest-version` and `unity-versions` outputs. The `perf-benchmarks` -matrix and both downstream `LATEST_VERSION` envs consume those outputs, so the -newest version lives in exactly one place. +The three licensed matrix workflows use literal matrices. Their policy entries +make a canonical-version bump fail until every static mirror is updated in the +same change. ## How to Bump a Version -1. Edit `.github/unity-versions.json` only. Append or change entries in `all` - (keep it strictly ascending), and set `release` if the pinned release version +1. Edit `.github/unity-versions.json`. Append or change entries in `all` (keep + it strictly ascending), and set `release` if the pinned release version moves. Adding a newer entry to the end of `all` redefines `latest`. 1. Run the validator: @@ -99,10 +97,10 @@ newest version lives in exactly one place. npm run validate:unity-versions ``` -1. If the validator flags a `mirror-all` or `mirror-release` consumer, update that - file so its literal set matches the new canonical value, then re-run the - validator until it passes. A `no-literals` failure means a workflow hardcoded a - version that it should read at runtime via `jq` instead. +1. If the validator flags a `mirror-all`, `mirror-latest`, or `mirror-release` + consumer, update that file so its literal set matches the new canonical + value, then re-run the validator until it passes. A `no-literals` failure + means an unregistered workflow introduced a version literal. The validator prints the resolved `all`, `latest`, `release`, and the count of consumer files checked on success, so you can confirm the bump landed. diff --git a/.llm/skills/unity-licensing/SKILL.md b/.llm/skills/unity-licensing/SKILL.md index 1074e548..f6fd8341 100644 --- a/.llm/skills/unity-licensing/SKILL.md +++ b/.llm/skills/unity-licensing/SKILL.md @@ -32,7 +32,10 @@ Local Unity verification needs NO license. The devcontainer ships no Unity build 1. **Return-at-start.** Each job calls `Invoke-UnityLicenseReturn` defensively before activating, reclaiming any seat a prior force-killed run leaked on that persistent runner. 1. **PowerShell `try`/`finally`.** `run-ci-tests.ps1` activates inside a `try` and returns in the `finally`, covering both a clean exit and an editor throw or non-zero exit. -1. **Workflow `if: always()` step.** Every Unity workflow runs `./.github/actions/return-unity-license` as an `if: always()` step INSIDE the org-lock window, before the lock release, so a step timeout or killed script still returns the license before the next job acquires the lock. +1. **Workflow terminal return step.** Every Unity workflow invokes the + centrally pinned `return-unity-license` action after diagnostics and before + classify/release/gate, scoped to an acquired lock, so a failed Unity step + still returns the license before the next job acquires the lock. 1. **The next run's return-at-start.** If the whole runner process is killed and the three layers above never run, layer 1 of the next run on that machine reclaims the seat. ### Per-job flow @@ -42,7 +45,8 @@ Validate the secrets with `./.github/actions/validate-unity-license` BEFORE acqu ### Contract invariants to check on every change - The return lives in a `finally`, and the defensive return-at-start is still present. -- Every Unity workflow keeps the `if: always()` `return-unity-license` step inside the org-lock window. +- Every Unity workflow keeps the acquired-scoped central return followed by + classifier, release, and final cleanup gate inside the org-lock window. - No workflow references `secrets.UNITY_LICENSING_SERVER`, and every Unity job wires all three serial secrets. Anti-patterns: returning the license only on the success path; dropping the `if: always()` step and relying on the next run's return-at-start; echoing or logging the serial or password; re-adding the retired licensing-server secret. diff --git a/.llm/skills/unity-licensing/references/unity-license-bootstrap.md b/.llm/skills/unity-licensing/references/unity-license-bootstrap.md index 4b4a6984..27548269 100644 --- a/.llm/skills/unity-licensing/references/unity-license-bootstrap.md +++ b/.llm/skills/unity-licensing/references/unity-license-bootstrap.md @@ -52,8 +52,8 @@ Unity.exe -quit -batchmode -nographics -returnlicense \ `run-ci-tests.ps1` wraps these as `Invoke-UnityLicenseActivate` (throws on failure) and `Invoke-UnityLicenseReturn` (best-effort, never throws). The license is returned on EVERY exit path through four redundant layers: a defensive -return-at-start, a PowerShell `try`/`finally` return, a workflow `if: always()` -step (`./.github/actions/return-unity-license`), and the next run's +return-at-start, a PowerShell `try`/`finally` return, the acquired-scoped +centrally pinned return/classify/release/gate chain, and the next run's return-at-start on the same persistent runner. Serial licenses have no server-side reclaim and only a small seat pool, so those return layers are the only thing that frees a seat -- the full guarantee, the seat-limit tradeoff, and @@ -78,9 +78,9 @@ Windows runners. Each workflow runs `./.github/actions/validate-unity-license` `UNITY_LICENSING_SERVER` is still set) BEFORE acquiring the central organization Unity lock, so a misconfigured license fails with a clear diagnostic before Unity starts or blocks the shared seat. Inside the org-lock window, every Unity -workflow also has an `if: always()` step (`./.github/actions/return-unity-license`) -that returns the license if the process is killed, placed before the lock -release. The `.github/workflows-disabled/*` files are ubuntu game-ci reference +workflow also has an acquired-scoped centrally pinned return action before +cleanup classification, release, and the final fail-closed gate. The +`.github/workflows-disabled/*` files are ubuntu game-ci reference mirrors that pass the serial via `unitySerial: ${{ secrets.UNITY_SERIAL }}`, `unityEmail`, and `unityPassword`. diff --git a/.llm/skills/unity-licensing/references/unity-license-return-guarantee.md b/.llm/skills/unity-licensing/references/unity-license-return-guarantee.md index b4d70591..9d6febd2 100644 --- a/.llm/skills/unity-licensing/references/unity-license-return-guarantee.md +++ b/.llm/skills/unity-licensing/references/unity-license-return-guarantee.md @@ -8,8 +8,8 @@ - Reviewing or changing `scripts/unity/run-ci-tests.ps1` license activation or return handling. -- Adding, removing, or reordering the `if: always()` license-return step - (`./.github/actions/return-unity-license`) in a Unity workflow. +- Adding, removing, or reordering the centrally pinned license-return, + classification, release, and gate steps in a Unity workflow. - Diagnosing a Unity job that fails to activate because all serial seats are consumed. - Reviewing any change to the Unity workflows' license activation/return steps. @@ -64,10 +64,10 @@ layers are the ONLY things that free a seat: 1. **PowerShell `try`/`finally` return.** `run-ci-tests.ps1` activates inside a `try` and calls `Invoke-UnityLicenseReturn` in the `finally`, so a clean exit AND an editor throw / non-zero both return the license. -1. **Workflow `if: always()` return step.** Every Unity workflow runs - `./.github/actions/return-unity-license` as an `if: always()` step inside the - org-lock window (before the lock release), so even a step timeout or killed - script process returns the license before the next job can acquire the lock. +1. **Workflow terminal return step.** Every Unity workflow invokes the + centrally pinned `return-unity-license` action after diagnostics and before + classify/release/gate, scoped to an acquired lock. A failed Unity step still + reaches this terminal cleanup chain before another job can acquire the lock. 1. **The next run's return-at-start.** On a persistent self-hosted runner, if all three layers above are somehow skipped (for example the whole runner process is killed), layer 1 of the NEXT run reclaims the leaked seat on that machine. @@ -88,8 +88,9 @@ Each Unity job follows this order: project. 1. Return: the PowerShell `finally` calls `Invoke-UnityLicenseReturn` on every exit path. -1. Workflow `if: always()` step runs `./.github/actions/return-unity-license` - inside the org-lock window, then the lock is released. +1. The acquired-scoped central return action runs inside the org-lock window, + followed by cleanup classification, exact lock release, and the final + fail-closed cleanup gate. ## The Seat-Limit Tradeoff (documented honestly) @@ -187,7 +188,8 @@ The cutover removed `UNITY_LICENSING_SERVER`. Re-wiring it is rejected by - Unity command-line arguments (`-serial`, `-returnlicense`): - Unity license activation methods: -- Source: `scripts/unity/run-ci-tests.ps1`, `.github/actions/return-unity-license/action.yml` +- Source: `scripts/unity/run-ci-tests.ps1` and the centrally pinned + `return-unity-license` action in each licensed workflow ## Changelog diff --git a/docs/ops/ambiguous-release-migration.md b/docs/ops/ambiguous-release-migration.md index 1274c085..f9f74f84 100644 --- a/docs/ops/ambiguous-release-migration.md +++ b/docs/ops/ambiguous-release-migration.md @@ -137,7 +137,6 @@ RAM-64GB]` so either Windows machine can pick up any Unity job. The - [ ] Confirm `ELI-MACHINE` retains its `fast` label for future opt-in use. - [ ] Confirm every licensed job in `.github/workflows/unity-tests.yml`, `.github/workflows/unity-benchmarks.yml`, - `.github/workflows/unity-gameci-experiment.yml`, `.github/workflows/perf-numbers.yml`, and `.github/workflows/release.yml` acquires `wallstop-organization-builds`, validates Unity license secrets, runs @@ -254,9 +253,10 @@ References: ## Semver Tag Release Flow -`.github/workflows/release.yml` runs only on tags matching `vX.Y.Z`. It validates -that the tag exactly matches `package.json` version before packing, attesting, -creating or updating the GitHub Release, and publishing to npm. +`.github/workflows/release.yml` is manually dispatched from a selected +`vX.Y.Z` tag. It validates that the selected ref is a tag and exactly matches +`package.json` version before packing, attesting, creating or updating the +GitHub Release, and publishing to npm. - [ ] Update `package.json` `version` to the intended public version. - [ ] Update `CHANGELOG.md` for the user-facing release. @@ -267,8 +267,9 @@ creating or updating the GitHub Release, and publishing to npm. `git tag -s vX.Y.Z` when signing is available, or the repository-approved annotated tag method when signing is not available. - [ ] Push only the intended release tag: `git push origin vX.Y.Z`. -- [ ] Confirm `.github/workflows/release.yml` starts on the tag and that the - `verify-tag`, `validate`, `unity-checks`, and `publish` jobs complete. +- [ ] Select the exact pushed tag and dispatch `.github/workflows/release.yml`. +- [ ] Confirm the `verify-tag`, `validate`, `unity-checks`, and `publish` jobs + complete. - [ ] Confirm the GitHub Release includes the `.tgz` package and `.sha256` artifact. - [ ] Confirm npm shows the new `com.wallstop-studios.dxmessaging` version with @@ -346,8 +347,8 @@ Run these checks after the transfer and again after the first tagged release. pick up any Unity job; cross-repository license serialization is enforced by the central `ambiguous-organization-build-lock` actions around the licensed Unity section. -- [ ] `.github/workflows/release.yml` succeeds for a real semver tag and does - not require `NPM_TOKEN`. +- [ ] `.github/workflows/release.yml` succeeds when dispatched from a real + semver tag and does not require `NPM_TOKEN`. - [ ] npm, OpenUPM, GitHub Releases, and GitHub Pages all point to `Ambiguous-Interactive/DxMessaging`. - [ ] If Unity has approved and published the Asset Store UPM listing, confirm diff --git a/docs/ops/ci-and-github-settings.md b/docs/ops/ci-and-github-settings.md index fa0937b8..6a5dad78 100644 --- a/docs/ops/ci-and-github-settings.md +++ b/docs/ops/ci-and-github-settings.md @@ -204,8 +204,7 @@ Unity test matrix: - `standalone` (native `StandaloneWindows64` IL2CPP player via `scripts/unity/run-ci-tests.ps1`, runtime-only assemblies) -Release checks default to `2022.3.45f1`. Benchmarks run on schedule -or manual dispatch only. +Release checks use `2022.3.45f1`. Benchmarks run only by manual dispatch. ## Licensed Job Guardrails diff --git a/docs/ops/npm-release-publishing.md b/docs/ops/npm-release-publishing.md index 41d87192..1cd14e6e 100644 --- a/docs/ops/npm-release-publishing.md +++ b/docs/ops/npm-release-publishing.md @@ -51,13 +51,15 @@ The normal path never creates the tag by hand: branch push or `v*` tag push. 1. Squash-merge that PR with its default `release: vX.Y.Z` title. 1. `.github/workflows/release-tag.yml` validates the merged commit and pushes - the annotated tag `vX.Y.Z` with the auto-commit GitHub App token, which - triggers the release workflow. + the annotated tag `vX.Y.Z` with the auto-commit GitHub App token. +1. Select that exact tag in the Actions UI and manually dispatch + `.github/workflows/release.yml`. A branch dispatch fails closed. -The manual fallback (App unavailable, or recovering from a partial run) is -pushing a strict semver tag that points at the reviewed release commit. Use a -signed tag when signing is available, or the repository-approved annotated -tag fallback when signing is not available: +The tag-creation fallback (App unavailable, or recovering from a partial run) +is pushing a strict semver tag that points at the reviewed release commit, then +dispatching the release workflow from that tag. Use a signed tag when signing +is available, or the repository-approved annotated tag fallback when signing +is not available: ```bash git checkout @@ -74,8 +76,8 @@ Before tagging, `package.json.version` must be `3.0.2`. The workflow rejects: - `v3.0.2-rc.1` - `v3.0.2` when `package.json.version` is still `3.0.1` -The only manual dispatch is `release-prepare.yml`; `release.yml` itself stays -tag-triggered. +Both release stages are manual dispatches: start with `release-prepare.yml`, +then dispatch `release.yml` from the exact reviewed tag after it exists. ## Release Gates diff --git a/docs/ops/release-operations.md b/docs/ops/release-operations.md index ebf69778..7b3db0d7 100644 --- a/docs/ops/release-operations.md +++ b/docs/ops/release-operations.md @@ -68,7 +68,7 @@ organization password manager. ## Release Model -The release pipeline runs dispatch, PR, tag, publish: +The release pipeline runs prepare dispatch, PR, tag, release dispatch, publish: 1. An operator dispatches `.github/workflows/release-prepare.yml` from the default branch with a bump kind (`patch`/`minor`/`major`) or an explicit @@ -92,16 +92,17 @@ The release pipeline runs dispatch, PR, tag, publish: manual `git tag -a vX.Y.Z -m "Release vX.Y.Z" && git push origin vX.Y.Z` commands as a warning instead. If the App token exists but cannot write the tag ref, the job fails with the same manual fallback commands. -1. The tag fires `.github/workflows/release.yml`, which performs the gates - below. +1. After the tag exists, an operator selects that exact tag in the Actions UI + and dispatches `.github/workflows/release.yml`. Branch selections fail the + tag verifier and cannot publish. The tag must exactly match `package.json.version` with a leading `v`. For example, package version `3.0.1` must be released from tag `v3.0.1`. -`release.yml` itself has no manual `workflow_dispatch` path; the manual entry -point is `release-prepare.yml`. A tag such as `3.0.1` or `v3.0.1-rc.1` does -not pass the release verifier. Pushing a valid tag by hand remains a -supported fallback when the App is unavailable. +`release.yml` is manual-dispatch only and must be dispatched from the reviewed +strict-semver tag. A ref such as a branch, `3.0.1`, or `v3.0.1-rc.1` does not +pass the release verifier. Pushing a valid tag by hand remains a supported +fallback when the App is unavailable. The release workflow performs these gates: diff --git a/docs/runbooks/perf-benchmark-methodology.md b/docs/runbooks/perf-benchmark-methodology.md index 3bdefa93..a7cd3eb4 100644 --- a/docs/runbooks/perf-benchmark-methodology.md +++ b/docs/runbooks/perf-benchmark-methodology.md @@ -217,8 +217,9 @@ driven by are sourced from this leg; the headline throughput stays on the Standalone leg. - **EditMode leg (not published)** also runs in-editor under Mono with `-releaseCodeOptimization`. It remains a fast scope for local iteration, and - the weekly `unity-benchmarks.yml` runs the editmode + playmode benchmark tests - per Unity version as coverage; EditMode numbers are not published. + manually dispatched `unity-benchmarks.yml` runs the editmode + playmode + benchmark tests per Unity version as coverage; EditMode numbers are not + published. The harness can exercise every scope through the shared protocol; `perf-numbers.yml` runs the **Standalone IL2CPP leg (throughput)** and the @@ -227,12 +228,10 @@ The harness can exercise every scope through the shared protocol; through the single organization build lock on the single `fast` runner, so adding the PlayMode leg increases the workflow's wall clock by the PlayMode leg's duration, which is shorter than the Standalone leg's because it builds no IL2CPP -player (well under 2x total). The PlayMode leg is also **non-blocking**: it is -marked `continue-on-error`, so a flaky PlayMode failure does NOT fail the -benchmark job for the downstream comment/commit jobs -- the Standalone throughput -headline and the regenerated baseline still publish, and that run simply omits the -allocation/byte tables (no PlayMode leg measured them) until the PlayMode leg is -healthy again (see [Editor-vs-player rationale](#editor-vs-player-rationale)). +player (well under 2x total). The PlayMode leg is required: a failure prevents +the aggregate performance gate and downstream publishing from succeeding, so +refreshed numbers cannot silently omit allocation evidence (see +[Editor-vs-player rationale](#editor-vs-player-rationale)). ## Scenario taxonomy @@ -423,20 +422,14 @@ matrix from the first scope present (Standalone in published runs) and BOTH the GC-allocation COUNT matrix and the GC-allocated-BYTES matrix from the first scope that measured each metric (PlayMode in published runs). -The PlayMode allocation leg is **non-blocking** (`continue-on-error` in -`perf-numbers.yml`, see [How CI produces and publishes the -numbers](#how-ci-produces-and-publishes-the-numbers)). The Standalone throughput -leg is the headline and the baseline source, so it gates publishing; a flaky -PlayMode leg must not. When the PlayMode leg fails, the matrix job still reports -success for the downstream comment/commit jobs, the Standalone headline and the -regenerated baseline publish as usual, and that run simply omits the -allocation/byte tables (no leg measured them) until the PlayMode leg is healthy -again. +The PlayMode allocation leg is required. A failure prevents the aggregate +performance gate and downstream publishing from succeeding, so refreshed +numbers cannot silently omit allocation evidence. EditMode runs inside the editor's hosting environment, is the least representative of shipping behavior, and is not published; it -- and PlayMode -- -remain useful for local iteration and for the weekly per-version benchmark-test -coverage in `unity-benchmarks.yml`. +remain useful for local iteration and for manually dispatched per-version +benchmark-test coverage in `unity-benchmarks.yml`. ## Baseline capture diff --git a/docs/runbooks/perf-numbers-auto-commit.md b/docs/runbooks/perf-numbers-auto-commit.md index caf194f3..0babafec 100644 --- a/docs/runbooks/perf-numbers-auto-commit.md +++ b/docs/runbooks/perf-numbers-auto-commit.md @@ -32,9 +32,10 @@ built-in `GITHUB_TOKEN`. The built-in token cannot push to a protected branch: branch-protection bypass actor and GitHub blocks the push by design. A dedicated GitHub App that **is** allowed to bypass the protection does the push instead. -The release pipeline uses the same App token: `release-prepare.yml` pushes the -`release/vX.Y.Z` branch and opens the release pull request, then -`release-tag.yml` pushes the annotated `vX.Y.Z` tag after that PR is merged. A +The release preparation pipeline uses the same App token: +`release-prepare.yml` pushes the `release/vX.Y.Z` branch and opens the release +pull request, then `release-tag.yml` pushes the annotated `vX.Y.Z` tag after +that PR is merged. An operator dispatches `release.yml` from that exact tag. A denied release-branch push that mentions `bot-auto-commit[bot]` and `Permission to ... denied` means the App token can be minted but cannot write that ref. Check the App installation, **Contents: Read and write**, **Pull diff --git a/docs/runbooks/required-checks.md b/docs/runbooks/required-checks.md index 80a840e4..2b073138 100644 --- a/docs/runbooks/required-checks.md +++ b/docs/runbooks/required-checks.md @@ -138,9 +138,8 @@ shape; require it only if devcontainer changes must gate merges. These never gate a pull request: -- Perf sticky-comment job in `perf-numbers.yml` (non-blocking by design); the - perf commit and release legs run on push and `workflow_dispatch`, never on a - pull request. +- `perf-numbers.yml`; its benchmark and publishing jobs run on default-branch + push and `workflow_dispatch`, never on a pull request. - Auto-fix workflows `csharpier-autofix.yml` and `prettier-autofix.yml`. Their visible `Format and propose changes` check is path-filtered (`**/*.cs`; `**/*.md` etc.), so it is absent on non-matching pull requests -- the same @@ -154,7 +153,7 @@ Markdown`, and `Prettier and yamllint` gates are the correct required checks. trigger (`unity-benchmarks.yml`, `release*.yml`, `runner-bootstrap.yml`, `update-llms-txt.yml`, `update-issue-template-versions.yml`, `sync-wiki.yml`, `markdown-link-validity.yml`, `stuck-job-watchdog.yml`, `unstick-run.yml`, - `devcontainer-prebuild.yml`, `unity-gameci-experiment.yml`). + `devcontainer-prebuild.yml`). ## Remediation: make a gate always-report @@ -362,7 +361,7 @@ the old path-filtered workflows did. A required check is matched by literal string, so these break silently: - **Matrix-interpolated names.** Do not require `Unity ` names. - The Unity matrix is generated from `.github/unity-versions.json`, and + The static Unity matrix is validated against `.github/unity-versions.json`, and job-level skip paths can prevent matrix expansion entirely, producing only the literal skipped check `Unity ${{ matrix.unity-version }} ${{ matrix.test-mode }}`. Require `Unity CI Success` instead. Static script tests are inside `ci.yml`; diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index 4d59fc99..da38eeda 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -12,13 +12,8 @@ const LOCK_ACTION_PREFIX = "Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/"; // Build-lock pins are bumped by Dependabot, so they are derived here rather than -// written as literals: a hardcoded SHA failed this suite (and the devcontainer -// smoke job that runs `npm test`) on every dependency bump, for a reason -// unrelated to the change under review. What must hold is that each action group -// stays immutably pinned and identical in every file that references it, so a -// bump either lands everywhere or fails loudly. `.github/actions/**` counts too: -// the cleanup-policy group reaches into a composite action there, which is why -// .github/dependabot.yml lists that directory. +// written as literals. What must hold is that each action group stays immutably +// pinned and identical at every call site. // prettier-ignore function resolveLockActionPin(actionNames) { const shas = new Map(); const comments = new Set(); const label = actionNames.join(", "); @@ -36,16 +31,15 @@ function resolveLockActionPin(actionNames) { return { sha: [...shas.keys()][0], comment: [...comments][0] || "" }; } -// Acquire, preflight, and the PR-head guard ship in the build-lock release; -// release/require-confirmed/classify carry the centralized Unity cleanup policy. +// Acquire, preflight, and the PR-head guard ship in the build-lock release. +// Return/classify/release/require-confirmed carry the centralized cleanup policy. // prettier-ignore -const [LOCK_ACTION_PIN, CLEANUP_POLICY_PIN] = [["check-unity-runner-availability", "acquire-build-lock", "require-current-pr-head"], ["release-build-lock", "require-confirmed-unity-cleanup", "classify-unity-cleanup-evidence"]].map((group) => resolveLockActionPin(group)); +const [LOCK_ACTION_PIN, CLEANUP_POLICY_PIN] = [["check-unity-runner-availability", "acquire-build-lock", "require-current-pr-head"], ["return-unity-license", "classify-unity-cleanup-evidence", "release-build-lock", "require-confirmed-unity-cleanup"]].map((group) => resolveLockActionPin(group)); const LOCK_ACTION_SHA = LOCK_ACTION_PIN.sha; const ACQUIRE_ACTION_SHA = LOCK_ACTION_PIN.sha; const CLEANUP_POLICY_SHA = CLEANUP_POLICY_PIN.sha; const UNITY_LOCK_WINDOWS = [ ["unity-tests.yml", "unity-tests", "Run Unity Test Runner", true], - ["unity-gameci-experiment.yml", "game-ci-experiment", "Run GameCI normal project mode", true], ["unity-benchmarks.yml", "benchmarks", "Run Unity Test Runner", true], ["release.yml", "unity-checks", "Run Unity Test Runner", true], ["release.yml", "unitypackage", "Export the .unitypackage", false], @@ -331,36 +325,164 @@ test("copyable build-lock documentation follows the runner and App credential co ); } }); -// prettier-ignore test("every Unity lock window releases with explicit cleanup proof", () => { const acquire = `uses: ${LOCK_ACTION_PREFIX}acquire-build-lock@${ACQUIRE_ACTION_SHA}${LOCK_ACTION_PIN.comment}`; + const returnLicense = `uses: ${LOCK_ACTION_PREFIX}return-unity-license@${CLEANUP_POLICY_SHA}`; + const classify = `uses: ${LOCK_ACTION_PREFIX}classify-unity-cleanup-evidence@${CLEANUP_POLICY_SHA}`; const release = `uses: ${LOCK_ACTION_PREFIX}release-build-lock@${CLEANUP_POLICY_SHA}`; const gate = `uses: ${LOCK_ACTION_PREFIX}require-confirmed-unity-cleanup@${CLEANUP_POLICY_SHA}`; - const runnerLabels = new Map([["perf-numbers.yml", '[["self-hosted","Windows","RAM-64GB","fast"]]'], ["release.yml", '[["self-hosted","Windows","RAM-64GB"]]'], ["unity-benchmarks.yml", '[["self-hosted","Windows","RAM-64GB"]]'], ["unity-gameci-experiment.yml", '[["self-hosted","Windows","RAM-64GB"]]'], ["unity-tests.yml", '[["self-hosted","Windows","RAM-64GB"]]']]); const preflightAction = `${LOCK_ACTION_PREFIX}check-unity-runner-availability@${LOCK_ACTION_SHA}${LOCK_ACTION_PIN.comment}`; const workflowSources = fs.readdirSync(WORKFLOW_DIR).filter((file) => /\.ya?ml$/.test(file)).map((file) => fs.readFileSync(path.join(WORKFLOW_DIR, file), "utf8")); for (const [file, labels] of runnerLabels) { const source = fs.readFileSync(path.join(WORKFLOW_DIR, file), "utf8"); const preflight = getJobBlock(source, "runner-preflight", file); assert.match(preflight, /\n runs-on: ubuntu-latest\n/, file); assert.match(preflight, new RegExp(`uses: ${escapeRegExp(preflightAction)}`), file); assert.match(preflight, /reader-app-id: \$\{\{ secrets\.BUILD_LOCK_READER_APP_ID \}\}/, file); assert.match(preflight, /reader-app-private-key: \$\{\{ secrets\.BUILD_LOCK_READER_APP_PRIVATE_KEY \}\}/, file); assert.match(preflight, new RegExp(`required-label-sets: '${escapeRegExp(labels)}'`), file); assert.doesNotMatch(preflight, /RUNNER_AUDIT_PAT|Soft pass|soft-pass/i, file); } - assert.equal(workflowSources.reduce((count, source) => count + source.split(acquire).length - 1, 0), UNITY_LOCK_WINDOWS.length); - assert.equal(workflowSources.reduce((count, source) => count + source.split(release).length - 1, 0), UNITY_LOCK_WINDOWS.length); + const runnerLabels = new Map([ + ["perf-numbers.yml", '[["self-hosted","Windows","RAM-64GB","fast"]]'], + ["release.yml", '[["self-hosted","Windows","RAM-64GB"]]'], + ["unity-benchmarks.yml", '[["self-hosted","Windows","RAM-64GB"]]'], + ["unity-tests.yml", '[["self-hosted","Windows","RAM-64GB"]]'] + ]); + const preflightAction = + `${LOCK_ACTION_PREFIX}check-unity-runner-availability@${LOCK_ACTION_SHA}` + + LOCK_ACTION_PIN.comment; + const workflowSources = fs + .readdirSync(WORKFLOW_DIR) + .filter((file) => /\.ya?ml$/.test(file)) + .map((file) => fs.readFileSync(path.join(WORKFLOW_DIR, file), "utf8")); + + for (const [file, labels] of runnerLabels) { + const source = fs.readFileSync(path.join(WORKFLOW_DIR, file), "utf8"); + const preflight = getJobBlock(source, "runner-preflight", file); + assert.match(preflight, /\n runs-on: ubuntu-latest\n/, file); + assert.match(preflight, new RegExp(`uses: ${escapeRegExp(preflightAction)}`), file); + assert.match(preflight, /reader-app-id: \$\{\{ secrets\.BUILD_LOCK_READER_APP_ID \}\}/, file); + assert.match( + preflight, + /reader-app-private-key: \$\{\{ secrets\.BUILD_LOCK_READER_APP_PRIVATE_KEY \}\}/, + file + ); + assert.match(preflight, new RegExp(`required-label-sets: '${escapeRegExp(labels)}'`), file); + assert.doesNotMatch(preflight, /RUNNER_AUDIT_PAT|Soft pass|soft-pass/i, file); + } + + for (const action of [acquire, returnLicense, classify, release, gate]) { + assert.equal( + workflowSources.reduce((count, source) => count + source.split(action).length - 1, 0), + UNITY_LOCK_WINDOWS.length, + action + ); + } + for (const [file, jobId, licensedWorkName, emptyAware] of UNITY_LOCK_WINDOWS) { - const label = `${file}:${jobId}`; const blockedCondition = `${emptyAware ? "steps\\.compute\\.outputs\\.is-empty != 'true' && " : ""}steps\\.acquire_lock\\.outputs\\.acquired != 'true'`; const licensedCondition = `${emptyAware ? "steps\\.compute\\.outputs\\.is-empty != 'true' && " : ""}steps\\.acquire_lock\\.outputs\\.acquired == 'true'`; + const label = `${file}:${jobId}`; + const licensedCondition = + `${emptyAware ? "steps\\.compute\\.outputs\\.is-empty != 'true' && " : ""}` + + "steps\\.acquire_lock\\.outputs\\.acquired == 'true'"; const source = fs.readFileSync(path.join(WORKFLOW_DIR, file), "utf8"); const job = getJobBlock(source, jobId, file); - assert.equal(job.split(acquire).length - 1, 1, `${label} acquire count`); - assert.equal(job.split(release).length - 1, 1, `${label} release count`); - assert.equal(job.split(gate).length - 1, 1, `${label} final cleanup gate count`); - const positions = ["Acquire organization Unity lock", "Require acquired Unity lock", licensedWorkName, "Return Unity license", "Release organization Unity lock", "Require confirmed Unity cleanup", "Delete private Unity cleanup evidence"].map((name) => job.indexOf(` - name: ${name}`)); - assert.ok(positions.every((position) => position >= 0), `${label} lifecycle steps must all exist`); - assert.deepEqual(positions, [...positions].sort((a, b) => a - b), `${label} lifecycle order`); - const orderedContract = ["- name: Acquire organization Unity lock\\n id: acquire_lock", escapeRegExp(acquire), "holder-id-suffix: (.+)\\n runner-id: (.+)\\n", `- name: Require acquired Unity lock\\n if: \\$\\{\\{ ${blockedCondition} \\}\\}\\n shell: pwsh\\n run: exit 1`, `- name: ${escapeRegExp(licensedWorkName)}[\\s\\S]*?if: \\$\\{\\{ ${licensedCondition} \\}\\}`, "- name: Return Unity license\\n id: return_unity_license\\n if: always\\(\\)\\n timeout-minutes: 5\\n continue-on-error: true\\n uses: \\.\\/\\.github\\/actions\\/return-unity-license", `- name: Release organization Unity lock\\n id: release_unity_lock\\n if: always\\(\\)\\n ${escapeRegExp(release)}`, "holder-id-suffix: \\1\\n runner-id: \\2\\n resource-cleanup-status: \\$\\{\\{ steps\\.return_unity_license\\.outputs\\.resource-cleanup-status \\}\\}\\n resource-health: \\$\\{\\{ steps\\.return_unity_license\\.outputs\\.resource-health \\}\\}\\n resource-reason: \\$\\{\\{ steps\\.return_unity_license\\.outputs\\.resource-reason \\}\\}", `- name: Require confirmed Unity cleanup[\\s\\S]*?${escapeRegExp(gate)}`, "classification-complete: \\$\\{\\{ steps\\.return_unity_license\\.outputs\\.classification-complete \\}\\}", "release-outcome: \\$\\{\\{ steps\\.release_unity_lock\\.outcome \\}\\}", "- name: Delete private Unity cleanup evidence"].join("[\\s\\S]*?"); - assert.match(job, new RegExp(orderedContract), label); - assert.doesNotMatch(job, /\n environment:/, `${label} must not require environment approval`); + if (["perf-numbers.yml", "unity-benchmarks.yml", "unity-tests.yml"].includes(file)) { + assert.match(job, /\n fail-fast: false\n max-parallel: 1\n/, `${label} fairness`); + } + for (const action of [acquire, returnLicense, classify, release, gate]) { + assert.equal(job.split(action).length - 1, 1, `${label}: ${action}`); + } + + const lifecycleNames = [ + "Acquire organization Unity lock", + "Require acquired Unity lock", + licensedWorkName, + "Return Unity license", + "Classify Unity cleanup evidence", + "Release organization Unity lock", + "Require confirmed Unity cleanup" + ]; + const positions = lifecycleNames.map((name) => job.indexOf(` - name: ${name}`)); + assert.ok( + positions.every((position) => position >= 0), + `${label} lifecycle steps must all exist` + ); + assert.deepEqual( + positions, + [...positions].sort((a, b) => a - b), + `${label} lifecycle order` + ); + + const acquireStep = getStepBlock(job, "Acquire organization Unity lock"); + const requireStep = getStepBlock(job, "Require acquired Unity lock"); + const workStep = getStepBlock(job, licensedWorkName); + const returnStep = getStepBlock(job, "Return Unity license"); + const classifyStep = getStepBlock(job, "Classify Unity cleanup evidence"); + const releaseStep = getStepBlock(job, "Release organization Unity lock"); + const gateStep = getStepBlock(job, "Require confirmed Unity cleanup"); + + assert.match(acquireStep, /\n id: acquire_lock\n/); + assert.match( + requireStep, + /\n if: \$\{\{ steps\.acquire_lock\.outputs\.acquired != 'true' \}\}\n[\s\S]*\n run: exit 1\n/ + ); + assert.match( + workStep, + new RegExp(`\\n if: \\$\\{\\{ ${licensedCondition} \\}\\}\\n`), + label + ); + assert.match( + returnStep, + /\n id: return_unity_license\n if: \$\{\{ always\(\) && steps\.acquire_lock\.outputs\.acquired == 'true' \}\}\n/ + ); + assert.doesNotMatch(returnStep, /continue-on-error:/); + assert.match( + classifyStep, + /\n id: cleanup_classification\n if: \$\{\{ always\(\) && steps\.acquire_lock\.outputs\.acquired == 'true' \}\}\n/ + ); + assert.match( + classifyStep, + /return-log-digest: \$\{\{ steps\.return_unity_license\.outputs\.return-log-digest \}\}/ + ); + assert.match(releaseStep, /\n id: release_unity_lock\n if: always\(\)\n/); + assert.match( + releaseStep, + /resource-cleanup-status: \$\{\{ steps\.cleanup_classification\.outputs\.resource-cleanup-status \}\}/ + ); + assert.match(gateStep, /\n if: always\(\)\n/); + assert.match( + gateStep, + /classification-complete: \$\{\{ steps\.cleanup_classification\.outputs\.classification-complete \}\}/ + ); + assert.match(gateStep, /release-outcome: \$\{\{ steps\.release_unity_lock\.outcome \}\}/); + + const acquireHolder = /holder-id-suffix: (.+)\n/.exec(acquireStep); + const releaseHolder = /holder-id-suffix: (.+)\n/.exec(releaseStep); + const acquireRunner = /runner-id: (.+)\n/.exec(acquireStep); + const releaseRunner = /runner-id: (.+)\n/.exec(releaseStep); + assert.ok(acquireHolder, `${label} acquire holder identity`); + assert.ok(releaseHolder, `${label} release holder identity`); + assert.ok(acquireRunner, `${label} acquire runner identity`); + assert.ok(releaseRunner, `${label} release runner identity`); + assert.equal(releaseHolder?.[1], acquireHolder?.[1], `${label} holder identity`); + assert.equal(releaseRunner?.[1], acquireRunner?.[1], `${label} runner identity`); + + assert.doesNotMatch( + job, + /\n environment:/, + `${label} must not require environment approval` + ); + assert.doesNotMatch(job, /Delete private Unity cleanup evidence/, label); } }); -// prettier-ignore -test("Unity return proof classifications remain fail closed and non-masking", () => { - const actionSource = fs.readFileSync(path.join(REPO_ROOT, ".github", "actions", "return-unity-license", "action.yml"), "utf8"); const packageSource = fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8"); const source = [actionSource, ...[path.join("scripts", "unity", "run-ci-tests.ps1"), path.join("scripts", "unity", "export-unitypackage.ps1")].map((file) => fs.readFileSync(path.join(REPO_ROOT, file), "utf8"))].join("\n"); - const classifications = [["central classifier", new RegExp(`classify-unity-cleanup-evidence@${CLEANUP_POLICY_SHA}`)], ["bounded prior metadata", /file\.Length -gt 25MB[\s\S]*?\^exit_return_rc=\(-\?\[0-9\]\+\)\$/], ["fallback retains prior evidence", /\$returnLogPath = \$file\.FullName[\s\S]*?\$supplementalPaths\.Add\(\$file\.FullName\)/], ["conservative capture defaults", /\$commandCompleted = \$false[\s\S]*?\$captureComplete = \$false/], ["private redundant return log", /Out-File -FilePath \$returnLog -Encoding utf8/], ["classification completion output", /classification-complete/], ["non-masking", /central release will fail closed/]]; - for (const [classification, pattern] of classifications) assert.match(source, pattern, classification); - assert.equal((source.match(/unity-return-preflight-/g) || []).length, 2); assert.equal((source.match(/Remove-Item -LiteralPath \$returnLogPath -Force/g) || []).length, 2); assert.match(source, /Add-Content -LiteralPath \$LogPath -Value "exit_return_rc=\$exitCode"/); assert.match(actionSource, /resource-health[\s\S]*resource-reason/); assert.doesNotMatch(actionSource, /if \(-not \$ready\) \{\s*\$supplementalPaths\.Add/); assert.doesNotMatch(actionSource, /Classify-Unity(?:AccountHealth|LicenseReturn)\.ps1/); assert.doesNotMatch(packageSource, /validate:unity-license-classifiers/); +test("Unity scripts retain bounded return-at-start evidence", () => { + const sources = [ + path.join("scripts", "unity", "run-ci-tests.ps1"), + path.join("scripts", "unity", "export-unitypackage.ps1") + ].map((file) => fs.readFileSync(path.join(REPO_ROOT, file), "utf8")); + const source = sources.join("\n"); + + assert.equal((source.match(/unity-return-preflight-/g) || []).length, 2); + assert.equal((source.match(/Remove-Item -LiteralPath \$returnLogPath -Force/g) || []).length, 2); + assert.equal( + (source.match(/Add-Content -LiteralPath \$LogPath -Value "exit_return_rc=\$exitCode"/g) || []) + .length, + 2 + ); + assert.doesNotMatch( + fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8"), + /validate:unity-license-classifiers/ + ); }); // prettier-ignore test("active workflows pin external actions and scope licensed credentials", () => { @@ -385,6 +507,7 @@ test("active workflows pin external actions and scope licensed credentials", () }); test("release workflows pin App write scopes and denied-push diagnostics", () => { + const release = fs.readFileSync(path.join(WORKFLOW_DIR, "release.yml"), "utf8"); const prepare = fs.readFileSync(path.join(WORKFLOW_DIR, "release-prepare.yml"), "utf8"); const tag = fs.readFileSync(path.join(WORKFLOW_DIR, "release-tag.yml"), "utf8"); // prettier-ignore @@ -397,5 +520,9 @@ test("release workflows pin App write scopes and denied-push diagnostics", () => ["tag App scope", tag, /- name: Generate the auto-commit GitHub App token[\s\S]*\n permission-contents: write\n/], ["tag diagnostics", tag, /- name: Create and push the annotated release tag[\s\S]*\n push_status=\$\{PIPESTATUS\[0\]\}\n[\s\S]*release tag push failure[\s\S]*Manual fallback:/] ]) assert.match(source, pattern, name); + assert.match( + getStepBlock(getJobBlock(release, "verify-tag"), "Verify semver tag matches package.json"), + /if \[ "\$\{GITHUB_REF_TYPE\}" != "tag" \]; then[\s\S]*exit 1/ + ); assert.doesNotMatch(prepare, /\.artifacts\/release-prepare/); }); diff --git a/scripts/__tests__/validate-unity-versions.test.js b/scripts/__tests__/validate-unity-versions.test.js index 4057bfd6..dc6046f0 100644 --- a/scripts/__tests__/validate-unity-versions.test.js +++ b/scripts/__tests__/validate-unity-versions.test.js @@ -99,6 +99,25 @@ test("checkConsumer enforces the mirror-release policy", () => { assert.ok(drift[0].includes("does not match canonical")); }); +test("checkConsumer enforces the mirror-latest policy", () => { + const base = { relativePath: "perf.yml", policy: "mirror-latest", ...VALID }; + assert.deepEqual( + checkConsumer({ + ...base, + literals: [{ version: VALID.all.at(-1), line: 4 }] + }), + [] + ); + assert.match( + checkConsumer({ + ...base, + literals: [{ version: VALID.all[0], line: 4 }] + })[0], + /does not match canonical latest/ + ); + assert.match(checkConsumer({ ...base, literals: [] })[0], /no Unity version literal/); +}); + test("checkConsumer enforces the mirror-all policy", () => { const base = { relativePath: "runner.ps1", policy: "mirror-all", ...VALID }; const exact = VALID.all.map((version, index) => ({ version, line: index + 1 })); diff --git a/scripts/validate-unity-pr-policy.py b/scripts/validate-unity-pr-policy.py index 6c2bcdee..82ef4ac7 100644 --- a/scripts/validate-unity-pr-policy.py +++ b/scripts/validate-unity-pr-policy.py @@ -13,12 +13,10 @@ WORKFLOW = Path(".github/workflows/unity-tests.yml") LOCK_ACTION_PREFIX = "Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/" REGISTERED_UNITY_AUTOMATION = { - ".github/actions/return-unity-license/action.yml", ".github/actions/validate-unity-license/action.yml", ".github/workflows/perf-numbers.yml", ".github/workflows/release.yml", ".github/workflows/unity-benchmarks.yml", - ".github/workflows/unity-gameci-experiment.yml", ".github/workflows/unity-tests.yml", } UNITY_CREDENTIAL_OR_ACTIVATION = re.compile( @@ -27,14 +25,16 @@ re.IGNORECASE, ) SAME_REPOSITORY_PR_GUARD = re.compile( - r"github\.event_name\s*!=\s*'pull_request'\s*\|\|\s*\(?\s*" + r"github\.event_name\s*!=\s*'pull_request'\s*\|\|\s*\(\s*" + r"github\.actor\s*!=\s*'dependabot\[bot\]'\s*&&\s*" r"github\.event\.pull_request\.head\.repo\.full_name\s*==\s*github\.repository" ) # Dependabot jobs read from the separate Dependabot secret store, so the # organization Unity serial and build-lock App secrets resolve empty there. The # licensed jobs must exclude Dependabot the same way they exclude forks. DEPENDABOT_PR_GUARD = re.compile( - r"github\.event\.pull_request\.user\.login\s*!=\s*'dependabot\[bot\]'" + r"github\.event_name\s*!=\s*'pull_request'\s*\|\|\s*\(?\s*" + r"github\.actor\s*!=\s*'dependabot\[bot\]'" ) BLANKET_PR_REJECTION = re.compile( r"github\.event_name\s*!=\s*'pull_request'\s*&&" @@ -200,7 +200,8 @@ def validate() -> None: ) require( SAME_REPOSITORY_PR_GUARD.search( - "github.event_name!='pull_request'||" + "github.event_name!='pull_request'||(" + "github.actor!='dependabot[bot]'&&" "github.event.pull_request.head.repo.full_name==github.repository" ) is not None, @@ -210,6 +211,14 @@ def validate() -> None: BLANKET_PR_REJECTION.search("github.event_name!='pull_request'&&(") is not None, "blanket rejection parser must detect compact operators", ) + require( + DEPENDABOT_PR_GUARD.search( + "github.event_name != 'pull_request' || " + "(github.actor != 'dependabot[bot]' && trusted)" + ) + is not None, + "Dependabot guard parser must require PR-only exclusion", + ) marker_cases = ( ("serial credential", "env: { UNITY_SERIAL: secret }"), ("email credential", "env: { UNITY_EMAIL: secret }"), @@ -233,11 +242,6 @@ def validate() -> None: {".github/workflows/unity-tests.yml": "env: { UNITY_SERIAL: secret }"}, [], ), - ( - "registered cleanup action", - {".github/actions/return-unity-license/action.yml": "env: { UNITY_EMAIL: secret }"}, - [], - ), ( "unregistered disabled workflow", {".github/workflows-disabled/unity-tests.yml": "env: { UNITY_PASSWORD: secret }"}, @@ -398,40 +402,60 @@ def validate() -> None: gate = job_block(source, "unity-ci-success") require("if: ${{ always() }}" in gate, "aggregate must always report") require("re-actors/alls-green" not in gate and "allowed-skips" not in gate, "skips must be typed") - for variable in ( - "MATRIX_CONFIG_RESULT", - "RUNNER_PREFLIGHT_RESULT", - "UNITY_TESTS_RESULT", - "FORK_PR", - "DEPENDABOT_PR", - "DOCS_ONLY", - ): - require(re.search(rf"^ {variable}:", gate, re.MULTILINE) is not None, f"missing {variable}") + require( + gate.count("\n - name:") == 1, + "aggregate must contain exactly one validation step", + ) + aggregate_step = step_block(gate, "Verify Unity CI result shape") + require(" shell: bash\n" in aggregate_step, "aggregate must use bash") + expected_bindings = { + "RUNNER_PREFLIGHT_RESULT": "${{ needs.runner-preflight.result }}", + "UNITY_TESTS_RESULT": "${{ needs.unity-tests.result }}", + "FORK_PR": ( + "${{ github.event_name == 'pull_request' && " + "github.event.pull_request.head.repo.full_name != github.repository }}" + ), + "DEPENDABOT_PR": ( + "${{ github.event_name == 'pull_request' && " + "github.actor == 'dependabot[bot]' }}" + ), + } + for variable, value in expected_bindings.items(): + require( + re.findall(rf"^ {variable}:.*$", aggregate_step, re.MULTILINE) + == [f" {variable}: {value}"], + f"aggregate must bind exact {variable}", + ) - script = run_script(step_block(gate, "Verify Unity CI result shape")) - # name, matrix-config, preflight, unity, FORK_PR, DEPENDABOT_PR, DOCS_ONLY, exit code + script = run_script(aggregate_step) + expected_script = """set -euo pipefail +if [ "${FORK_PR}" = "true" ] || [ "${DEPENDABOT_PR}" = "true" ]; then + test "${RUNNER_PREFLIGHT_RESULT}" = skipped + test "${UNITY_TESTS_RESULT}" = skipped +else + test "${RUNNER_PREFLIGHT_RESULT}" = success + test "${UNITY_TESTS_RESULT}" = success +fi""" + require(script == expected_script, "aggregate result-shape script drifted") + # name, preflight, unity, FORK_PR, DEPENDABOT_PR, exit code cases = ( - ("same-repository PR", "success", "success", "success", "false", "false", "false", 0), - ("fork PR", "success", "skipped", "skipped", "true", "false", "false", 0), - ("Dependabot PR", "success", "skipped", "skipped", "false", "true", "false", 0), - ("CI-owned docs-only PR", "success", "success", "skipped", "false", "false", "true", 0), - ("same-repository PR skipped Unity", "success", "success", "skipped", "false", "false", "false", 1), - ("fork unexpectedly ran Unity", "success", "skipped", "success", "true", "false", "false", 1), - ("Dependabot unexpectedly ran Unity", "success", "skipped", "success", "false", "true", "false", 1), - ("Dependabot unexpectedly ran preflight", "success", "success", "skipped", "false", "true", "false", 1), - ("matrix config failed", "failure", "success", "success", "false", "false", "false", 1), + ("same-repository PR", "success", "success", "false", "false", 0), + ("fork PR", "skipped", "skipped", "true", "false", 0), + ("Dependabot PR", "skipped", "skipped", "false", "true", 0), + ("same-repository PR skipped Unity", "success", "skipped", "false", "false", 1), + ("fork unexpectedly ran Unity", "skipped", "success", "true", "false", 1), + ("Dependabot unexpectedly ran Unity", "skipped", "success", "false", "true", 1), + ("Dependabot unexpectedly ran preflight", "success", "skipped", "false", "true", 1), ) if os.name != "nt": - for name, matrix, preflight, unity, fork, dependabot, docs_only, expected in cases: + for name, preflight, unity, fork, dependabot, expected in cases: environment = os.environ.copy() environment.update( { - "MATRIX_CONFIG_RESULT": matrix, "RUNNER_PREFLIGHT_RESULT": preflight, "UNITY_TESTS_RESULT": unity, "FORK_PR": fork, "DEPENDABOT_PR": dependabot, - "DOCS_ONLY": docs_only, } ) result = subprocess.run( diff --git a/scripts/validate-unity-versions.js b/scripts/validate-unity-versions.js index 0c8bfa86..b49c08ed 100644 --- a/scripts/validate-unity-versions.js +++ b/scripts/validate-unity-versions.js @@ -15,19 +15,16 @@ * } * * `latest` is DEFINED as the last element of `all` (it is never stored - * separately). Three ubuntu-bash workflows READ this file at runtime via jq - * (perf-numbers.yml, unity-tests.yml, unity-benchmarks.yml) and therefore must - * carry ZERO version literals in their own YAML. The self-hosted / pwsh / static - * consumers cannot easily read the JSON at runtime, so they keep literal version - * strings; THIS validator is what keeps those literals honest -- it fails CI if - * any consumer drifts from the canonical file. + * separately). Licensed workflow matrices are literal and static so the + * organization build-lock analyzer can prove every lock identity before the + * workflow runs. THIS validator keeps those literals honest and fails CI if any + * consumer drifts from the canonical file. * * Per-file policies (see CONSUMER_POLICIES): - * - `no-literals` : the file must contain NO code version literal. Applied to - * the three jq-reading workflows AND, by default, to every - * other `.github/workflows/*.yml`, so a NEW workflow that - * hardcodes a version is caught automatically. + * - `no-literals` : the file must contain NO code version literal. Applied by + * default to active workflows not explicitly registered. * - `mirror-all` : the SET of code literals must equal `all` exactly. + * - `mirror-latest` : every code literal must equal the last `all` entry. * - `mirror-release`: every code literal must equal `release`, and there must * be at least one. * @@ -75,14 +72,13 @@ const VERSION_LITERAL_ANCHORED_REGEX = /^[0-9]+\.[0-9]+\.[0-9]+[abfp][0-9]+$/; * caught. The disabled-archive directory is excluded entirely. */ const CONSUMER_POLICIES = Object.freeze({ - ".github/workflows/perf-numbers.yml": "no-literals", - ".github/workflows/unity-tests.yml": "no-literals", - ".github/workflows/unity-benchmarks.yml": "no-literals", + ".github/workflows/perf-numbers.yml": "mirror-latest", + ".github/workflows/unity-tests.yml": "mirror-all", + ".github/workflows/unity-benchmarks.yml": "mirror-all", ".github/workflows/runner-bootstrap.yml": "mirror-all", "scripts/unity/maintain-windows-runner.ps1": "mirror-all", "scripts/unity/install-runner-maintenance-task.ps1": "mirror-all", - ".github/workflows/release.yml": "mirror-release", - ".github/workflows/unity-gameci-experiment.yml": "mirror-release" + ".github/workflows/release.yml": "mirror-release" }); /** @@ -298,7 +294,7 @@ function extractVersionLiterals(content, extension) { * @param {object} params Parameters. * @param {string} params.relativePath Repo-relative POSIX path (for messages). * @param {string} params.policy One of "no-literals" | "mirror-all" | - * "mirror-release". + * "mirror-latest" | "mirror-release". * @param {Array<{ version: string, line: number }>} params.literals Literals * from extractVersionLiterals. * @param {string[]} params.all The canonical `all` set. @@ -338,6 +334,25 @@ function checkConsumer({ relativePath, policy, literals, all, release }) { return violations; } + if (policy === "mirror-latest") { + const latest = all.at(-1); + if (literals.length === 0) { + return [ + `${relativePath}:0: no Unity version literal found; expected at least one equal to ` + + `latest '${latest}'.` + ]; + } + for (const { version, line } of literals) { + if (version !== latest) { + violations.push( + `${relativePath}:${line}: Unity version '${version}' does not match canonical ` + + `latest; expected '${latest}'.` + ); + } + } + return violations; + } + if (policy === "mirror-all") { const allSet = new Set(all); const foundSet = new Set(); From 387368c6dda9d5d35696f71eee9ab801107dd1f3 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 30 Jul 2026 08:35:07 +0000 Subject: [PATCH 02/22] ci: align Unity provisioning with cleanup root --- .github/workflows/perf-numbers.yml | 1 + .github/workflows/release.yml | 2 ++ .github/workflows/unity-benchmarks.yml | 1 + .github/workflows/unity-tests.yml | 7 +++--- .../__tests__/ci-aggregate-workflow.test.js | 23 +++++++++++++++++++ 5 files changed, 31 insertions(+), 3 deletions(-) diff --git a/.github/workflows/perf-numbers.yml b/.github/workflows/perf-numbers.yml index 67dd8f41..c4ae0018 100644 --- a/.github/workflows/perf-numbers.yml +++ b/.github/workflows/perf-numbers.yml @@ -257,6 +257,7 @@ jobs: $provisioningProfile = if ('${{ matrix.test-mode }}' -eq 'standalone') { 'StandaloneWindowsIl2Cpp' } else { 'EditorOnly' } $editor = ./scripts/unity/ensure-editor.ps1 ` -UnityVersion '${{ matrix.unity-version }}' ` + -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` -CiManagedOnly ` -RequireHealthyExisting ` -ProvisioningProfile $provisioningProfile ` diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e1d4e02..b32b7516 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -247,6 +247,7 @@ jobs: New-Item -ItemType Directory -Force -Path $diagnosticsPath | Out-Null $editor = ./scripts/unity/ensure-editor.ps1 ` -UnityVersion '2022.3.45f1' ` + -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` -CiManagedOnly ` -RequireHealthyExisting ` -ProvisioningProfile EditorOnly ` @@ -461,6 +462,7 @@ jobs: New-Item -ItemType Directory -Force -Path $diagnosticsPath | Out-Null $editor = ./scripts/unity/ensure-editor.ps1 ` -UnityVersion '2022.3.45f1' ` + -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` -CiManagedOnly ` -RequireHealthyExisting ` -ProvisioningProfile EditorOnly ` diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml index 1214867e..c7c17df3 100644 --- a/.github/workflows/unity-benchmarks.yml +++ b/.github/workflows/unity-benchmarks.yml @@ -137,6 +137,7 @@ jobs: New-Item -ItemType Directory -Force -Path $diagnosticsPath | Out-Null $editor = ./scripts/unity/ensure-editor.ps1 ` -UnityVersion '${{ matrix.unity-version }}' ` + -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` -CiManagedOnly ` -RequireHealthyExisting ` -ProvisioningProfile EditorOnly ` diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 298e313d..1c0c2011 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -37,7 +37,7 @@ jobs: if: >- ${{ github.event_name != 'pull_request' || - (github.actor != 'dependabot[bot]' && + (github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: ubuntu-latest @@ -65,7 +65,7 @@ jobs: if: >- ${{ github.event_name != 'pull_request' || - (github.actor != 'dependabot[bot]' && + (github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: [self-hosted, Windows, RAM-64GB] @@ -181,6 +181,7 @@ jobs: } $editor = ./scripts/unity/ensure-editor.ps1 ` -UnityVersion '${{ matrix.unity-version }}' ` + -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` -CiManagedOnly ` -RequireHealthyExisting ` -ProvisioningProfile $provisioningProfile ` @@ -382,7 +383,7 @@ jobs: RUNNER_PREFLIGHT_RESULT: ${{ needs.runner-preflight.result }} UNITY_TESTS_RESULT: ${{ needs.unity-tests.result }} FORK_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} - DEPENDABOT_PR: ${{ github.event_name == 'pull_request' && github.actor == 'dependabot[bot]' }} + DEPENDABOT_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dependabot[bot]' }} run: | set -euo pipefail if [ "${FORK_PR}" = "true" ] || [ "${DEPENDABOT_PR}" = "true" ]; then diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index da38eeda..7838e7d2 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -403,6 +403,7 @@ test("every Unity lock window releases with explicit cleanup proof", () => { ); const acquireStep = getStepBlock(job, "Acquire organization Unity lock"); + const provisionStep = getStepBlock(job, "Provision Unity Editor"); const requireStep = getStepBlock(job, "Require acquired Unity lock"); const workStep = getStepBlock(job, licensedWorkName); const returnStep = getStepBlock(job, "Return Unity license"); @@ -411,6 +412,11 @@ test("every Unity lock window releases with explicit cleanup proof", () => { const gateStep = getStepBlock(job, "Require confirmed Unity cleanup"); assert.match(acquireStep, /\n id: acquire_lock\n/); + assert.match( + provisionStep, + /-InstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, + `${label}: central cleanup and provisioning must use the same trusted editor root` + ); assert.match( requireStep, /\n if: \$\{\{ steps\.acquire_lock\.outputs\.acquired != 'true' \}\}\n[\s\S]*\n run: exit 1\n/ @@ -484,6 +490,23 @@ test("Unity scripts retain bounded return-at-start evidence", () => { /validate:unity-license-classifiers/ ); }); + +test("Unity Tests classifies Dependabot pull requests by immutable PR author", () => { + const source = fs.readFileSync(path.join(WORKFLOW_DIR, "unity-tests.yml"), "utf8"); + const preflight = getJobBlock(source, "runner-preflight", "unity-tests.yml"); + const licensed = getJobBlock(source, "unity-tests", "unity-tests.yml"); + const aggregate = getJobBlock(source, "unity-ci-success", "unity-tests.yml"); + + for (const job of [preflight, licensed]) { + assert.match(job, /github\.event\.pull_request\.user\.login != 'dependabot\[bot\]'/); + assert.doesNotMatch(job, /github\.actor != 'dependabot\[bot\]'/); + } + assert.match( + aggregate, + /DEPENDABOT_PR: \$\{\{ github\.event_name == 'pull_request' && github\.event\.pull_request\.user\.login == 'dependabot\[bot\]' \}\}/ + ); + assert.doesNotMatch(aggregate, /github\.actor == 'dependabot\[bot\]'/); +}); // prettier-ignore test("active workflows pin external actions and scope licensed credentials", () => { for (const filePath of [WORKFLOW_DIR, path.join(REPO_ROOT, ".github", "actions")].flatMap((root) => walkFiles(root, { match: (file) => /\.ya?ml$/.test(file) }))) { From 268cb558b3aebc1da7827caa79bca11195c2940a Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 30 Jul 2026 09:00:53 +0000 Subject: [PATCH 03/22] ci: provision the managed Unity editor root --- .github/workflows/unity-tests.yml | 2 +- scripts/__tests__/ci-aggregate-workflow.test.js | 14 ++++++++++++++ scripts/validate-unity-pr-policy.py | 10 +++++----- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 1c0c2011..d7a2c7b7 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -183,7 +183,6 @@ jobs: -UnityVersion '${{ matrix.unity-version }}' ` -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` -CiManagedOnly ` - -RequireHealthyExisting ` -ProvisioningProfile $provisioningProfile ` -DiagnosticsPath $diagnosticsFile "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append @@ -248,6 +247,7 @@ jobs: run: | ./scripts/unity/run-ci-tests.ps1 ` -UnityVersion '${{ matrix.unity-version }}' ` + -UnityInstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` -TestMode '${{ matrix.test-mode }}' ` -AssemblyNames $env:DXM_TEST_ASSEMBLIES ` -ArtifactsPath '.artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}' ` diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index 7838e7d2..38121b6b 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -417,6 +417,13 @@ test("every Unity lock window releases with explicit cleanup proof", () => { /-InstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, `${label}: central cleanup and provisioning must use the same trusted editor root` ); + if (file === "unity-tests.yml") { + assert.doesNotMatch( + provisionStep, + /-RequireHealthyExisting/, + `${label}: provisioning must be able to populate the trusted editor root` + ); + } assert.match( requireStep, /\n if: \$\{\{ steps\.acquire_lock\.outputs\.acquired != 'true' \}\}\n[\s\S]*\n run: exit 1\n/ @@ -426,6 +433,13 @@ test("every Unity lock window releases with explicit cleanup proof", () => { new RegExp(`\\n if: \\$\\{\\{ ${licensedCondition} \\}\\}\\n`), label ); + if (file === "unity-tests.yml") { + assert.match( + workStep, + /-UnityInstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, + `${label}: licensed work and central return must use the same trusted editor root` + ); + } assert.match( returnStep, /\n id: return_unity_license\n if: \$\{\{ always\(\) && steps\.acquire_lock\.outputs\.acquired == 'true' \}\}\n/ diff --git a/scripts/validate-unity-pr-policy.py b/scripts/validate-unity-pr-policy.py index 82ef4ac7..635e3676 100644 --- a/scripts/validate-unity-pr-policy.py +++ b/scripts/validate-unity-pr-policy.py @@ -26,7 +26,7 @@ ) SAME_REPOSITORY_PR_GUARD = re.compile( r"github\.event_name\s*!=\s*'pull_request'\s*\|\|\s*\(\s*" - r"github\.actor\s*!=\s*'dependabot\[bot\]'\s*&&\s*" + r"github\.event\.pull_request\.user\.login\s*!=\s*'dependabot\[bot\]'\s*&&\s*" r"github\.event\.pull_request\.head\.repo\.full_name\s*==\s*github\.repository" ) # Dependabot jobs read from the separate Dependabot secret store, so the @@ -34,7 +34,7 @@ # licensed jobs must exclude Dependabot the same way they exclude forks. DEPENDABOT_PR_GUARD = re.compile( r"github\.event_name\s*!=\s*'pull_request'\s*\|\|\s*\(?\s*" - r"github\.actor\s*!=\s*'dependabot\[bot\]'" + r"github\.event\.pull_request\.user\.login\s*!=\s*'dependabot\[bot\]'" ) BLANKET_PR_REJECTION = re.compile( r"github\.event_name\s*!=\s*'pull_request'\s*&&" @@ -201,7 +201,7 @@ def validate() -> None: require( SAME_REPOSITORY_PR_GUARD.search( "github.event_name!='pull_request'||(" - "github.actor!='dependabot[bot]'&&" + "github.event.pull_request.user.login!='dependabot[bot]'&&" "github.event.pull_request.head.repo.full_name==github.repository" ) is not None, @@ -214,7 +214,7 @@ def validate() -> None: require( DEPENDABOT_PR_GUARD.search( "github.event_name != 'pull_request' || " - "(github.actor != 'dependabot[bot]' && trusted)" + "(github.event.pull_request.user.login != 'dependabot[bot]' && trusted)" ) is not None, "Dependabot guard parser must require PR-only exclusion", @@ -417,7 +417,7 @@ def validate() -> None: ), "DEPENDABOT_PR": ( "${{ github.event_name == 'pull_request' && " - "github.actor == 'dependabot[bot]' }}" + "github.event.pull_request.user.login == 'dependabot[bot]' }}" ), } for variable, value in expected_bindings.items(): From 26133ed89b3cc0bc5f7f420a5054f396525acc27 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 30 Jul 2026 09:31:47 +0000 Subject: [PATCH 04/22] ci: let central action own Unity return --- .github/workflows/unity-tests.yml | 1 + .../__tests__/ci-aggregate-workflow.test.js | 18 ++++++++++++++++++ scripts/unity/run-ci-tests.ps1 | 13 +++++++------ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index d7a2c7b7..619f4522 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -251,6 +251,7 @@ jobs: -TestMode '${{ matrix.test-mode }}' ` -AssemblyNames $env:DXM_TEST_ASSEMBLIES ` -ArtifactsPath '.artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}' ` + -LicenseReturnOwner Central ` -ReleaseCodeOptimization ` -ReleasePlayerBuild diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index 38121b6b..1eeea2e3 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -439,6 +439,11 @@ test("every Unity lock window releases with explicit cleanup proof", () => { /-UnityInstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, `${label}: licensed work and central return must use the same trusted editor root` ); + assert.match( + workStep, + /-LicenseReturnOwner Central/, + `${label}: the trusted central action must own the post-activation return` + ); } assert.match( returnStep, @@ -505,6 +510,19 @@ test("Unity scripts retain bounded return-at-start evidence", () => { ); }); +test("Unity test CI defers exactly one post-activation return to the central action", () => { + const source = fs.readFileSync( + path.join(REPO_ROOT, "scripts", "unity", "run-ci-tests.ps1"), + "utf8" + ); + assert.match(source, /\[ValidateSet\('Local', 'Central'\)\]/); + assert.match(source, /\[string\]\$LicenseReturnOwner = 'Local'/); + assert.match( + source, + /if \(\$hasLicenseCreds -and \$LicenseReturnOwner -eq 'Local'\) \{\s+Invoke-UnityLicenseReturn/ + ); +}); + test("Unity Tests classifies Dependabot pull requests by immutable PR author", () => { const source = fs.readFileSync(path.join(WORKFLOW_DIR, "unity-tests.yml"), "utf8"); const preflight = getJobBlock(source, "runner-preflight", "unity-tests.yml"); diff --git a/scripts/unity/run-ci-tests.ps1 b/scripts/unity/run-ci-tests.ps1 index e93c1c1f..5797b837 100644 --- a/scripts/unity/run-ci-tests.ps1 +++ b/scripts/unity/run-ci-tests.ps1 @@ -34,6 +34,9 @@ param( [switch]$ReleasePlayerBuild, + [ValidateSet('Local', 'Central')] + [string]$LicenseReturnOwner = 'Local', + [switch]$GenerateOnly ) @@ -3245,12 +3248,10 @@ try { 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) { + # Standalone/local callers retain deterministic return. Organization CI passes + # Central so the immediately following trusted return action owns the one + # authoritative post-activation attempt and its typed evidence. + if ($hasLicenseCreds -and $LicenseReturnOwner -eq 'Local') { Invoke-UnityLicenseReturn -EditorPath $UnityEditorPath -Email $env:UNITY_EMAIL -Password $env:UNITY_PASSWORD -LogPath $returnLogPath } } From 8de6f3910e1fb73024b57cd5f81c62487db56d6e Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 30 Jul 2026 09:39:19 +0000 Subject: [PATCH 05/22] ci: pin serial-redacting Unity return --- .github/workflows/perf-numbers.yml | 8 ++++---- .github/workflows/release.yml | 16 ++++++++-------- .github/workflows/unity-benchmarks.yml | 8 ++++---- .github/workflows/unity-tests.yml | 8 ++++---- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/perf-numbers.yml b/.github/workflows/perf-numbers.yml index c4ae0018..00dafe99 100644 --- a/.github/workflows/perf-numbers.yml +++ b/.github/workflows/perf-numbers.yml @@ -391,7 +391,7 @@ jobs: id: return_unity_license if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 5 - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@0ce3dce6cbe29af210432087e3b6d81509258063 with: unity-version: ${{ matrix.unity-version }} tool-cache: ${{ runner.tool_cache }} @@ -403,7 +403,7 @@ jobs: id: cleanup_classification if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 2 - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@0ce3dce6cbe29af210432087e3b6d81509258063 with: return-log-path: ${{ steps.return_unity_license.outputs.return-log-path }} return-command-completed: ${{ steps.return_unity_license.outputs.return-command-completed }} @@ -414,7 +414,7 @@ jobs: - name: Release organization Unity lock id: release_unity_lock if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@0ce3dce6cbe29af210432087e3b6d81509258063 with: lock-name: wallstop-organization-builds holder-id-suffix: perf-${{ matrix.unity-version }}-${{ matrix.test-mode }} @@ -428,7 +428,7 @@ jobs: - name: Require confirmed Unity cleanup if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@0ce3dce6cbe29af210432087e3b6d81509258063 with: acquired: ${{ steps.acquire_lock.outputs.acquired }} classification-complete: ${{ steps.cleanup_classification.outputs.classification-complete }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b32b7516..3675b830 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -346,7 +346,7 @@ jobs: id: return_unity_license if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 5 - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@0ce3dce6cbe29af210432087e3b6d81509258063 with: unity-version: 2022.3.45f1 tool-cache: ${{ runner.tool_cache }} @@ -358,7 +358,7 @@ jobs: id: cleanup_classification if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 2 - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@0ce3dce6cbe29af210432087e3b6d81509258063 with: return-log-path: ${{ steps.return_unity_license.outputs.return-log-path }} return-command-completed: ${{ steps.return_unity_license.outputs.return-command-completed }} @@ -369,7 +369,7 @@ jobs: - name: Release organization Unity lock id: release_unity_lock if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@0ce3dce6cbe29af210432087e3b6d81509258063 with: lock-name: wallstop-organization-builds holder-id-suffix: release-editmode @@ -383,7 +383,7 @@ jobs: - name: Require confirmed Unity cleanup if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@0ce3dce6cbe29af210432087e3b6d81509258063 with: acquired: ${{ steps.acquire_lock.outputs.acquired }} classification-complete: ${{ steps.cleanup_classification.outputs.classification-complete }} @@ -548,7 +548,7 @@ jobs: id: return_unity_license if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 5 - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@0ce3dce6cbe29af210432087e3b6d81509258063 with: unity-version: 2022.3.45f1 tool-cache: ${{ runner.tool_cache }} @@ -560,7 +560,7 @@ jobs: id: cleanup_classification if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 2 - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@0ce3dce6cbe29af210432087e3b6d81509258063 with: return-log-path: ${{ steps.return_unity_license.outputs.return-log-path }} return-command-completed: ${{ steps.return_unity_license.outputs.return-command-completed }} @@ -571,7 +571,7 @@ jobs: - name: Release organization Unity lock id: release_unity_lock if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@0ce3dce6cbe29af210432087e3b6d81509258063 with: lock-name: wallstop-organization-builds holder-id-suffix: release-unitypackage @@ -585,7 +585,7 @@ jobs: - name: Require confirmed Unity cleanup if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@0ce3dce6cbe29af210432087e3b6d81509258063 with: acquired: ${{ steps.acquire_lock.outputs.acquired }} classification-complete: ${{ steps.cleanup_classification.outputs.classification-complete }} diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml index c7c17df3..42e8d45f 100644 --- a/.github/workflows/unity-benchmarks.yml +++ b/.github/workflows/unity-benchmarks.yml @@ -247,7 +247,7 @@ jobs: id: return_unity_license if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 5 - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@0ce3dce6cbe29af210432087e3b6d81509258063 with: unity-version: ${{ matrix.unity-version }} tool-cache: ${{ runner.tool_cache }} @@ -259,7 +259,7 @@ jobs: id: cleanup_classification if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 2 - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@0ce3dce6cbe29af210432087e3b6d81509258063 with: return-log-path: ${{ steps.return_unity_license.outputs.return-log-path }} return-command-completed: ${{ steps.return_unity_license.outputs.return-command-completed }} @@ -270,7 +270,7 @@ jobs: - name: Release organization Unity lock id: release_unity_lock if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@0ce3dce6cbe29af210432087e3b6d81509258063 with: lock-name: wallstop-organization-builds holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} @@ -284,7 +284,7 @@ jobs: - name: Require confirmed Unity cleanup if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@0ce3dce6cbe29af210432087e3b6d81509258063 with: acquired: ${{ steps.acquire_lock.outputs.acquired }} classification-complete: ${{ steps.cleanup_classification.outputs.classification-complete }} diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 619f4522..26c9c70d 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -307,7 +307,7 @@ jobs: id: return_unity_license if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 5 - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/return-unity-license@0ce3dce6cbe29af210432087e3b6d81509258063 with: unity-version: ${{ matrix.unity-version }} tool-cache: ${{ runner.tool_cache }} @@ -319,7 +319,7 @@ jobs: id: cleanup_classification if: ${{ always() && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 2 - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/classify-unity-cleanup-evidence@0ce3dce6cbe29af210432087e3b6d81509258063 with: return-log-path: ${{ steps.return_unity_license.outputs.return-log-path }} return-command-completed: ${{ steps.return_unity_license.outputs.return-command-completed }} @@ -330,7 +330,7 @@ jobs: - name: Release organization Unity lock id: release_unity_lock if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@0ce3dce6cbe29af210432087e3b6d81509258063 with: lock-name: wallstop-organization-builds holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} @@ -344,7 +344,7 @@ jobs: - name: Require confirmed Unity cleanup if: always() - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@08fc83e83fa4cae89c0177005b388585ffdb1d9a + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@0ce3dce6cbe29af210432087e3b6d81509258063 with: acquired: ${{ steps.acquire_lock.outputs.acquired }} classification-complete: ${{ steps.cleanup_classification.outputs.classification-complete }} From 70c3d3a77f6c9977c4cc33fd1de2c575c3120170 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 30 Jul 2026 10:18:10 +0000 Subject: [PATCH 06/22] ci: centralize every final Unity return --- .github/workflows/perf-numbers.yml | 1 + .github/workflows/release.yml | 4 ++- .github/workflows/unity-benchmarks.yml | 1 + .../__tests__/ci-aggregate-workflow.test.js | 32 +++++++++---------- scripts/unity/export-unitypackage.ps1 | 7 ++-- 5 files changed, 26 insertions(+), 19 deletions(-) diff --git a/.github/workflows/perf-numbers.yml b/.github/workflows/perf-numbers.yml index 00dafe99..2540748c 100644 --- a/.github/workflows/perf-numbers.yml +++ b/.github/workflows/perf-numbers.yml @@ -328,6 +328,7 @@ jobs: -TestMode '${{ matrix.test-mode }}' ` -AssemblyNames $env:DXM_TEST_ASSEMBLIES ` -ArtifactsPath '.artifacts/unity/perf/${{ matrix.unity-version }}-${{ matrix.test-mode }}' ` + -LicenseReturnOwner Central ` @extra - name: Capture exact standalone player size diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3675b830..04ed67fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -297,6 +297,7 @@ jobs: -TestMode 'editmode' ` -AssemblyNames $env:DXM_TEST_ASSEMBLIES ` -ArtifactsPath '.artifacts/unity/release-editmode' ` + -LicenseReturnOwner Central ` -ReleaseCodeOptimization ` -ReleasePlayerBuild @@ -516,7 +517,8 @@ jobs: ./scripts/unity/export-unitypackage.ps1 ` -UnityVersion '2022.3.45f1' ` -OutputPath $output ` - -ArtifactsPath '.artifacts/unity/release-unitypackage' + -ArtifactsPath '.artifacts/unity/release-unitypackage' ` + -LicenseReturnOwner Central - name: Dump Unity log tail on failure or cancellation if: ${{ failure() || cancelled() }} diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml index 42e8d45f..a2c58e90 100644 --- a/.github/workflows/unity-benchmarks.yml +++ b/.github/workflows/unity-benchmarks.yml @@ -192,6 +192,7 @@ jobs: -TestMode '${{ matrix.test-mode }}' ` -AssemblyNames $env:DXM_TEST_ASSEMBLIES ` -ArtifactsPath '.artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }}' ` + -LicenseReturnOwner Central ` -ReleaseCodeOptimization ` -ReleasePlayerBuild diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index 1eeea2e3..aa36b3c5 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -439,12 +439,12 @@ test("every Unity lock window releases with explicit cleanup proof", () => { /-UnityInstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, `${label}: licensed work and central return must use the same trusted editor root` ); - assert.match( - workStep, - /-LicenseReturnOwner Central/, - `${label}: the trusted central action must own the post-activation return` - ); } + assert.match( + workStep, + /-LicenseReturnOwner Central/, + `${label}: the trusted central action must own the post-activation return` + ); assert.match( returnStep, /\n id: return_unity_license\n if: \$\{\{ always\(\) && steps\.acquire_lock\.outputs\.acquired == 'true' \}\}\n/ @@ -510,17 +510,17 @@ test("Unity scripts retain bounded return-at-start evidence", () => { ); }); -test("Unity test CI defers exactly one post-activation return to the central action", () => { - const source = fs.readFileSync( - path.join(REPO_ROOT, "scripts", "unity", "run-ci-tests.ps1"), - "utf8" - ); - assert.match(source, /\[ValidateSet\('Local', 'Central'\)\]/); - assert.match(source, /\[string\]\$LicenseReturnOwner = 'Local'/); - assert.match( - source, - /if \(\$hasLicenseCreds -and \$LicenseReturnOwner -eq 'Local'\) \{\s+Invoke-UnityLicenseReturn/ - ); +test("Unity CI defers every post-activation return to the central action", () => { + for (const file of ["run-ci-tests.ps1", "export-unitypackage.ps1"]) { + const source = fs.readFileSync(path.join(REPO_ROOT, "scripts", "unity", file), "utf8"); + assert.match(source, /\[ValidateSet\('Local', 'Central'\)\]/, file); + assert.match(source, /\[string\]\$LicenseReturnOwner = 'Local'/, file); + assert.match( + source, + /if \(\$hasLicenseCreds -and \$LicenseReturnOwner -eq 'Local'\) \{\s+Invoke-UnityLicenseReturn/, + file + ); + } }); test("Unity Tests classifies Dependabot pull requests by immutable PR author", () => { diff --git a/scripts/unity/export-unitypackage.ps1 b/scripts/unity/export-unitypackage.ps1 index f490bc53..400352db 100644 --- a/scripts/unity/export-unitypackage.ps1 +++ b/scripts/unity/export-unitypackage.ps1 @@ -46,7 +46,10 @@ param( # Stage the project and generate the exporter without running Unity # (local inspection of the staged payload). - [switch]$StageOnly + [switch]$StageOnly, + + [ValidateSet('Local', 'Central')] + [string]$LicenseReturnOwner = 'Local' ) Set-StrictMode -Version Latest @@ -851,7 +854,7 @@ try { $size = (Get-Item -LiteralPath $OutputPath).Length Write-Host "::notice::Exported $OutputPath ($size bytes)." } finally { - if ($hasLicenseCreds) { + if ($hasLicenseCreds -and $LicenseReturnOwner -eq 'Local') { Invoke-UnityLicenseReturn -EditorPath $UnityEditorPath -Email $env:UNITY_EMAIL -Password $env:UNITY_PASSWORD -LogPath $returnLogPath } } From 934c1b83de5af82255fdd43d708de096f25e5a0c Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 30 Jul 2026 10:19:07 +0000 Subject: [PATCH 07/22] ci: provision every trusted editor root --- .github/workflows/perf-numbers.yml | 1 - .github/workflows/release.yml | 2 -- .github/workflows/unity-benchmarks.yml | 1 - scripts/__tests__/ci-aggregate-workflow.test.js | 12 +++++------- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/perf-numbers.yml b/.github/workflows/perf-numbers.yml index 2540748c..673681ad 100644 --- a/.github/workflows/perf-numbers.yml +++ b/.github/workflows/perf-numbers.yml @@ -259,7 +259,6 @@ jobs: -UnityVersion '${{ matrix.unity-version }}' ` -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` -CiManagedOnly ` - -RequireHealthyExisting ` -ProvisioningProfile $provisioningProfile ` -DiagnosticsPath $diagnosticsFile "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04ed67fa..8b59900b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -249,7 +249,6 @@ jobs: -UnityVersion '2022.3.45f1' ` -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` -CiManagedOnly ` - -RequireHealthyExisting ` -ProvisioningProfile EditorOnly ` -DiagnosticsPath $diagnosticsFile "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append @@ -465,7 +464,6 @@ jobs: -UnityVersion '2022.3.45f1' ` -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` -CiManagedOnly ` - -RequireHealthyExisting ` -ProvisioningProfile EditorOnly ` -DiagnosticsPath $diagnosticsFile "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml index a2c58e90..cf563e65 100644 --- a/.github/workflows/unity-benchmarks.yml +++ b/.github/workflows/unity-benchmarks.yml @@ -139,7 +139,6 @@ jobs: -UnityVersion '${{ matrix.unity-version }}' ` -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` -CiManagedOnly ` - -RequireHealthyExisting ` -ProvisioningProfile EditorOnly ` -DiagnosticsPath $diagnosticsFile "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index aa36b3c5..b9c0ed1a 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -417,13 +417,11 @@ test("every Unity lock window releases with explicit cleanup proof", () => { /-InstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, `${label}: central cleanup and provisioning must use the same trusted editor root` ); - if (file === "unity-tests.yml") { - assert.doesNotMatch( - provisionStep, - /-RequireHealthyExisting/, - `${label}: provisioning must be able to populate the trusted editor root` - ); - } + assert.doesNotMatch( + provisionStep, + /-RequireHealthyExisting/, + `${label}: provisioning must be able to populate the trusted editor root` + ); assert.match( requireStep, /\n if: \$\{\{ steps\.acquire_lock\.outputs\.acquired != 'true' \}\}\n[\s\S]*\n run: exit 1\n/ From a99a6da846c33dda322dc258cbd0b83da714a7c1 Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 30 Jul 2026 16:41:48 +0000 Subject: [PATCH 08/22] test(editor): reject PrintWindow capture automation --- .../session-177-safe-editor-capture-proof.md | 147 ++++++++++++++++++ ...sion-177-safe-editor-capture-proof.md.meta | 7 + scripts/__tests__/design-system-dumps.test.js | 1 + 3 files changed, 155 insertions(+) create mode 100644 progress/session-177-safe-editor-capture-proof.md create mode 100644 progress/session-177-safe-editor-capture-proof.md.meta diff --git a/progress/session-177-safe-editor-capture-proof.md b/progress/session-177-safe-editor-capture-proof.md new file mode 100644 index 00000000..6af109ac --- /dev/null +++ b/progress/session-177-safe-editor-capture-proof.md @@ -0,0 +1,147 @@ +# Session 177 - safe editor capture proof and PR recovery + +Date: 2026-07-30 +Branch: `codex/issue-305-enrollment-remediation` +PR: **#316** + +## Outcome + +The remaining editor-tooling screenshot work is narrower than the prior plan +recorded. Unity 6000.4.6f1 can render a genuine Editor panel into an offscreen +`RenderTexture`, so the devcontainer does not need a desktop screenshot API. The +method produced visually inspectable proof for both acceptance surfaces: + +- the real Message Monitor with live package UI and data; +- a real Inspector containing `MessagingComponentEditor` and the current Message + subscriptions section. + +The host reports `EditorGUIUtility.isProSkin == true` and `UserSkin == 1`. The +proofs are therefore dark-theme experiments, not documentation replacements. +The manifest requests Unity 2022.3 LTS, while the configured host is 6000.4.6f1. +The remaining WS-7.3 work is to resolve that version choice, select Personal/light +through the Unity UI, capture the complete set, inspect it, and replace the +tracked images and manifest. + +This session also closed issue #317 after auditing its attached archive. The +archive was the rejected branding prototype already covered by the plan: a +second package, demo-seeded Monitor, experimental GraphView, duplicate +`Window/DxMessaging/*` menus, and copies of the shipped icon and theme assets. +No source from the archive was adopted. + +## Capture experiment + +The public screenshot tools target cameras or the Scene view, and standard +Editor windows report the Windows offscreen sentinel position in this host. A +panel render avoids both constraints: + +1. Create a temporary `HideAndDontSave` Editor window and attach the real shipped + view, or use the real Inspector panel after selecting a temporary component. +1. Give the panel visual tree a fixed size and validate layout. +1. In this linear-color project, allocate the temporary target with + `RenderTextureReadWrite.Linear`, make it active, set its viewport, clear it, + set `GL.sRGBWrite` to `false`, repaint the panel, and invoke its render path. +1. Read the pixels into a linear `Texture2D`. An earlier sRGB target produced + washed-out grays; the linear rerun visually matched the real dark panels and + amber borders. +1. Close the temporary windows, destroy transient objects, restore selection and + render state, and clear the Console. + +This reads only the render target created for the experiment. It does not read +the desktop, call `ReadScreenPixel`, invoke `PrintWindow`, move a window onscreen, +or change the Unity skin. + +The safe path produced two saved dark proof artifacts: + +| Surface | Artifact | Size | SHA-256 | +| --- | --- | ---: | --- | +| Message Monitor | `.artifacts/design-system-screens/session-177/monitor-rendertexture-linear-dark-proof.png` | 52,633 bytes | `05648b02b622abef71be100b1ca8e200d9dd4bb0ea71eba109468fb0ec1cca3d` | +| Inspector | `.artifacts/design-system-screens/session-177/inspector-rendertexture-linear-dark-proof.png` | 91,877 bytes | `2f32ef372817523014816b201eb083cef47181bc851d102a0f376e55082e1453` | + +Both were visually inspected at native resolution. The 720x520 Monitor is +correctly oriented and shows the real package view and current component data. +The 1000x700 Inspector is correctly oriented and shows the real Enemy Drone +Inspector, `MessagingComponentEditor`, and current Message subscriptions section. +Neither contains desktop content or sensitive data. They remain dark-theme +mechanism evidence, not final documentation assets. + +Two earlier dark screenshots were also visually inspected: + +| Surface | Artifact | Size | SHA-256 | +| --- | --- | ---: | --- | +| Message Monitor | `.artifacts/design-system-screens/session-177/monitor-unity-screenshots-api-dark-proof.png` | 66,558 bytes | `353dea246bbb1b9f873d9a084c1b10b83cf48960e43f6e831aa7b9b6b87f04db` | +| Inspector | `.artifacts/design-system-screens/session-177/inspector-unity-screenshots-api-dark-proof.png` | 95,050 bytes | `48f3f2be6766b1139c21cd72920c4897a3861379b92556a2d5eb4d5e1bce005c` | + +Both have the correct orientation and real shipped content, with no desktop, +VS Code, or sensitive data. They were created through Unity's internal +`ScreenShots` API before its source was audited. UnityCsReference shows that API +delegates to `InternalEditorUtility.ReadScreenPixel`, so these files are unsafe +historical staging evidence, not acceptance or RenderTexture evidence. + +The render-target, `GL.sRGBWrite`, active-target, selection, and window state were +restored in cleanup. The Inspector render emitted transient +`EditorGUIUtility.AddCursorRect called outside an editor OnGUI` diagnostics while +painting its IMGUI body into the offscreen panel; after capture the Console was +cleared and a fresh error query returned zero entries. Unity was idle, no +temporary Monitor or Inspector remained, and selection was clear. +Exactly the seven standard windows remained: MainToolbar, Project, Inspector, +Hierarchy, Scene, Game, and Console. + +## Screenshot automation guard + +The plan and issue #314 prohibit both `ReadScreenPixel` and `PrintWindow`, but the +repository test guarded only the first API. Added `PrintWindow` to the blocked +capture primitives in `scripts/__tests__/design-system-dumps.test.js`. + +The targeted test passed before and after the change. A temporary C# probe that +contained `PrintWindow` made the same test fail with the expected path and token; +removing the probe restored green: + +```text +tests 3 +pass 3 +fail 0 +``` + +The full Node suite also passed: 404 tests, 0 failures. `npm run +validate:all` passed every repository validation gate. The two Unity screenshot +surface fixtures then ran through `DxMcpTestRunner`: + +```text +passCount: 64 +failCount: 0 +skipCount: 0 +durationSeconds: 1.7148067 +``` + +The result is retained at +`.artifacts/unity-mcp/session-177-screenshot-surfaces.json`. Post-test state +remained idle and clean, with empty selection, the seven standard windows, and +zero Console errors. + +## GitHub and CI state + +PR #316 remains the repository's only open PR and closes issue #305. Its static +CI passed. The original Unity 6000.3 standalone job failed before test execution +while repairing a missing Windows IL2CPP module: another process held the managed +Editor directory, three quarantine attempts failed, and the cleanup gate +correctly failed closed. + +A controlled rerun of only that failed job was started before changing the PR +head. Its result distinguishes transient runner state from a repeatable +provisioning defect. The branch will then be synchronized with current `master` +so it carries the session 176 prototype-exclusion guard before a full matrix is +requested. + +## Remaining work + +- Finish the controlled Unity 6000.3 standalone rerun and act on its evidence. +- Synchronize PR #316 with current `master`, validate, push, and obtain current + reviews and required checks. +- Switch the host to Personal/light through the Unity UI. +- Resolve the manifest's Unity 2022.3 LTS requirement versus the configured + 6000.4.6f1 host. +- Repeat the offscreen Inspector and Message Monitor captures. Capture the native + menu cascade and combined Hierarchy/Inspector frames manually, or explicitly + revise their scope. +- Inspect every final image, then update the documentation screenshots and + manifest together. diff --git a/progress/session-177-safe-editor-capture-proof.md.meta b/progress/session-177-safe-editor-capture-proof.md.meta new file mode 100644 index 00000000..fedf3abd --- /dev/null +++ b/progress/session-177-safe-editor-capture-proof.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 18842513a82a42919300ac97aac6243c +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/design-system-dumps.test.js b/scripts/__tests__/design-system-dumps.test.js index 81c946a4..6730b276 100644 --- a/scripts/__tests__/design-system-dumps.test.js +++ b/scripts/__tests__/design-system-dumps.test.js @@ -84,6 +84,7 @@ test("editor-window screenshot automation does not use blocked capture primitive const violations = []; const blockedPatterns = [ ["ReadScreenPixel", /\bReadScreenPixel\b/], + ["PrintWindow", /\bPrintWindow\b/], ["SwitchSkinAndRepaintAllViews", /\bSwitchSkinAndRepaintAllViews\b/] ]; From 982f4105cd13ef98f3202cb886a7161554a1bb9a Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 30 Jul 2026 17:22:12 +0000 Subject: [PATCH 09/22] ci: serialize editor provisioning under lock --- .github/workflows/perf-numbers.yml | 62 +++++----- .github/workflows/release.yml | 82 ++++++------- .github/workflows/runner-bootstrap.yml | 109 ++---------------- .github/workflows/unity-benchmarks.yml | 41 +++---- .github/workflows/unity-tests.yml | 65 ++++++----- docs/ops/ambiguous-release-migration.md | 2 +- docs/runbooks/required-checks.md | 2 +- docs/runbooks/unity-runners-after-transfer.md | 19 ++- .../__tests__/ci-aggregate-workflow.test.js | 31 ++++- 9 files changed, 178 insertions(+), 235 deletions(-) diff --git a/.github/workflows/perf-numbers.yml b/.github/workflows/perf-numbers.yml index 673681ad..4177c9fc 100644 --- a/.github/workflows/perf-numbers.yml +++ b/.github/workflows/perf-numbers.yml @@ -73,7 +73,7 @@ permissions: jobs: runner-preflight: - name: Self-hosted runner access preflight + name: Self-hosted runner registration preflight # Runs on GitHub-hosted infrastructure before the self-hosted matrix can # queue. The dedicated reader App makes missing inventory a hard failure. runs-on: ubuntu-latest @@ -86,7 +86,7 @@ jobs: outputs: has-autocommit-app: ${{ steps.check-autocommit-app.outputs.has-app }} steps: - - name: Require an online performance runner + - name: Require a registered performance runner uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/check-unity-runner-availability@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: reader-app-id: ${{ secrets.BUILD_LOCK_READER_APP_ID }} @@ -232,21 +232,28 @@ jobs: -OutputJson (Join-Path $artifactsPath 'machine-specs.json') ` -OutputSummary (Join-Path $artifactsPath 'machine-specs.txt') - # Editor install/repair does NOT require the paid Unity seat, so it runs - # BEFORE the org lock. The Standalone (IL2CPP) leg builds a real IL2CPP - # player, so it provisions StandaloneWindowsIl2Cpp (Windows Build Support - # with the IL2CPP scripting backend). The PlayMode leg runs IN-EDITOR (Mono) - # for allocation capture and builds no player, so it provisions EditorOnly - # -- matching run-ci-tests.ps1's own 'EditorOnly' choice for a non-standalone - # mode. Skip provisioning, the org lock, and the run when the - # compute step resolved an empty assembly list, so an empty discovery never - # takes the paid Unity seat / org lock only to fail late at verify. The - # verify step runs only after compute succeeds and is told via - # expected-empty. Mirrors unity-tests.yml. The perf list is structurally - # non-empty, so this is defense-in-depth and uniformity with the required - # workflows. + - name: Acquire organization Unity lock + id: acquire_lock + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 + with: + lock-name: wallstop-organization-builds + holder-id-suffix: perf-${{ matrix.unity-version }}-${{ matrix.test-mode }} + runner-id: ${{ runner.name }} + timeout-minutes: "300" + env: + BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} + BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} + + - name: Require acquired Unity lock + if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} + shell: pwsh + run: exit 1 + + # The Standalone leg provisions IL2CPP support; PlayMode needs only the + # editor. Both mutate the shared tool-cache root, so installation and + # repair are serialized by the organization lock before licensed work. - name: Provision Unity Editor - if: ${{ steps.compute.outputs.is-empty != 'true' }} + if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 180 shell: pwsh run: | @@ -254,7 +261,11 @@ jobs: $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' } + $provisioningProfile = if ('${{ matrix.test-mode }}' -eq 'standalone') { + 'StandaloneWindowsIl2Cpp' + } else { + 'EditorOnly' + } $editor = ./scripts/unity/ensure-editor.ps1 ` -UnityVersion '${{ matrix.unity-version }}' ` -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` @@ -272,23 +283,6 @@ jobs: if-no-files-found: warn retention-days: 14 - - name: Acquire organization Unity lock - id: acquire_lock - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - lock-name: wallstop-organization-builds - holder-id-suffix: perf-${{ matrix.unity-version }}-${{ matrix.test-mode }} - runner-id: ${{ runner.name }} - timeout-minutes: "300" - env: - BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} - BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - - - name: Require acquired Unity lock - if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} - shell: pwsh - run: exit 1 - - name: Run Unity Test Runner id: run_tests if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b59900b..d528e675 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -158,7 +158,7 @@ jobs: subject-path: .artifacts/release/*.tgz runner-preflight: - name: Self-hosted runner access preflight + name: Self-hosted runner registration preflight needs: verify-tag runs-on: ubuntu-latest timeout-minutes: 3 @@ -166,7 +166,7 @@ jobs: group: ${{ github.workflow }}-runner-preflight-${{ github.ref }} cancel-in-progress: true steps: - - name: Require an online Unity runner + - name: Require a registered Unity runner uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/check-unity-runner-availability@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: reader-app-id: ${{ secrets.BUILD_LOCK_READER_APP_ID }} @@ -236,8 +236,26 @@ jobs: # only after compute succeeds and is told the run was an expected skip via # expected-empty. Mirrors unity-tests.yml. For the current asmdef set this # never triggers; it is the robustness path for an empty target. + - name: Acquire organization Unity lock + id: acquire_lock + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 + with: + lock-name: wallstop-organization-builds + holder-id-suffix: release-editmode + runner-id: ${{ runner.name }} + timeout-minutes: "300" + env: + BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} + BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} + + - name: Require acquired Unity lock + if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} + shell: pwsh + run: exit 1 + + # Serialize shared tool-cache installation and repair with licensed work. - name: Provision Unity Editor - if: ${{ steps.compute.outputs.is-empty != 'true' }} + if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 180 shell: pwsh run: | @@ -262,23 +280,6 @@ jobs: if-no-files-found: warn retention-days: 14 - - name: Acquire organization Unity lock - id: acquire_lock - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - lock-name: wallstop-organization-builds - holder-id-suffix: release-editmode - runner-id: ${{ runner.name }} - timeout-minutes: "300" - env: - BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} - BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - - - name: Require acquired Unity lock - if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} - shell: pwsh - run: exit 1 - - name: Run Unity Test Runner id: run_tests if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} @@ -448,11 +449,27 @@ jobs: UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - # Editor install/repair does NOT take the paid Unity seat, so it runs - # BEFORE the org lock (mirrors unity-checks). The export only needs the - # editor itself; EditorOnly is the same profile unity-checks provisions, - # so on the runner that ran unity-checks this is a fast health check. + - name: Acquire organization Unity lock + id: acquire_lock + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 + with: + lock-name: wallstop-organization-builds + holder-id-suffix: release-unitypackage + runner-id: ${{ runner.name }} + timeout-minutes: "300" + env: + BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} + BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} + + - name: Require acquired Unity lock + if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} + shell: pwsh + run: exit 1 + + # The exporter needs only EditorOnly, but its shared cache mutation still + # requires organization serialization before licensed work begins. - name: Provision Unity Editor + if: ${{ steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 180 shell: pwsh run: | @@ -481,23 +498,6 @@ jobs: # against immutable artifact names without overwrite. overwrite: true - - name: Acquire organization Unity lock - id: acquire_lock - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - lock-name: wallstop-organization-builds - holder-id-suffix: release-unitypackage - runner-id: ${{ runner.name }} - timeout-minutes: "300" - env: - BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} - BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - - - name: Require acquired Unity lock - if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} - shell: pwsh - run: exit 1 - - name: Export the .unitypackage id: export_unitypackage if: ${{ steps.acquire_lock.outputs.acquired == 'true' }} diff --git a/.github/workflows/runner-bootstrap.yml b/.github/workflows/runner-bootstrap.yml index 38e30531..0df45afd 100644 --- a/.github/workflows/runner-bootstrap.yml +++ b/.github/workflows/runner-bootstrap.yml @@ -96,116 +96,23 @@ permissions: jobs: # Preflight convention: every job whose runs-on declares `self-hosted` - # must `needs:` a runner-access preflight (see + # must `needs:` a runner-registration preflight (see # docs/runbooks/unity-runners-after-transfer.md). This mirrors the # release.yml runner-preflight pattern exactly. runner-preflight: - name: Self-hosted runner access preflight + name: Self-hosted runner registration 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 - # release.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::" + - name: Require the selected Unity runner to be registered + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/check-unity-runner-availability@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 + with: + reader-app-id: ${{ secrets.BUILD_LOCK_READER_APP_ID }} + reader-app-private-key: ${{ secrets.BUILD_LOCK_READER_APP_PRIVATE_KEY }} + required-label-sets: '[["self-hosted","Windows","RAM-64GB","${{ inputs.runner-label }}"]]' bootstrap: # F9: surface the mode (Audit vs Maintenance) in the job display name so diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml index cf563e65..f90d716b 100644 --- a/.github/workflows/unity-benchmarks.yml +++ b/.github/workflows/unity-benchmarks.yml @@ -20,14 +20,14 @@ permissions: jobs: runner-preflight: - name: Self-hosted runner access preflight + name: Self-hosted runner registration preflight runs-on: ubuntu-latest timeout-minutes: 3 concurrency: group: ${{ github.workflow }}-runner-preflight-${{ github.ref }} cancel-in-progress: true steps: - - name: Require an online Unity runner + - name: Require a registered Unity runner uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/check-unity-runner-availability@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: reader-app-id: ${{ secrets.BUILD_LOCK_READER_APP_ID }} @@ -126,8 +126,26 @@ jobs: # runs only after compute succeeds and is told via expected-empty. Mirrors # unity-tests.yml. The benchmark list is structurally non-empty, so this # is defense-in-depth and uniformity with the required workflows. + - name: Acquire organization Unity lock + id: acquire_lock + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 + with: + lock-name: wallstop-organization-builds + holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} + runner-id: ${{ runner.name }} + timeout-minutes: "300" + env: + BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} + BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} + + - name: Require acquired Unity lock + if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} + shell: pwsh + run: exit 1 + + # Serialize shared tool-cache installation and repair with licensed work. - name: Provision Unity Editor - if: ${{ steps.compute.outputs.is-empty != 'true' }} + if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 180 shell: pwsh run: | @@ -152,23 +170,6 @@ jobs: if-no-files-found: warn retention-days: 90 - - name: Acquire organization Unity lock - id: acquire_lock - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - lock-name: wallstop-organization-builds - holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} - runner-id: ${{ runner.name }} - timeout-minutes: "300" - env: - BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} - BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - - - name: Require acquired Unity lock - if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} - shell: pwsh - run: exit 1 - - name: Run Unity Test Runner id: run_tests if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 26c9c70d..e417df2f 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -23,7 +23,7 @@ concurrency: jobs: runner-preflight: - name: Self-hosted runner access preflight + name: Self-hosted runner registration preflight # Runs on GitHub-hosted infrastructure before any self-hosted matrix entry # can queue. Reader-App or inventory failures are intentionally fail closed. # @@ -46,7 +46,7 @@ jobs: group: ${{ github.workflow }}-runner-preflight-${{ github.ref }} cancel-in-progress: true steps: - - name: Require an online Unity runner + - name: Require a registered Unity runner uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/check-unity-runner-availability@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: reader-app-id: ${{ secrets.BUILD_LOCK_READER_APP_ID }} @@ -165,8 +165,39 @@ jobs: # 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 + - name: Require current PR head before lock acquisition if: ${{ steps.compute.outputs.is-empty != 'true' }} + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-current-pr-head@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 + with: + github-token: ${{ github.token }} + pull-request-number: ${{ github.event.pull_request.number }} + expected-head-sha: ${{ github.event.pull_request.head.sha }} + + - name: Acquire organization Unity lock + id: acquire_lock + uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 + with: + lock-name: wallstop-organization-builds + holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} + runner-id: ${{ runner.name }} + github-token: ${{ github.token }} + pull-request-number: ${{ github.event.pull_request.number }} + expected-head-sha: ${{ github.event.pull_request.head.sha }} + timeout-minutes: "300" + env: + BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} + BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} + + - name: Require acquired Unity lock + if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} + shell: pwsh + run: exit 1 + + # The shared tool-cache editor root is organization-wide runner state. + # Provision only while holding the same lock that serializes licensed + # work, so concurrent repositories cannot repair or replace one editor. + - name: Provision Unity Editor + if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 180 shell: pwsh run: | @@ -196,34 +227,6 @@ jobs: if-no-files-found: warn retention-days: 14 - - name: Require current PR head before lock acquisition - if: ${{ steps.compute.outputs.is-empty != 'true' }} - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-current-pr-head@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - github-token: ${{ github.token }} - pull-request-number: ${{ github.event.pull_request.number }} - expected-head-sha: ${{ github.event.pull_request.head.sha }} - - - name: Acquire organization Unity lock - id: acquire_lock - uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 - with: - lock-name: wallstop-organization-builds - holder-id-suffix: ${{ matrix.unity-version }}-${{ matrix.test-mode }} - runner-id: ${{ runner.name }} - github-token: ${{ github.token }} - pull-request-number: ${{ github.event.pull_request.number }} - expected-head-sha: ${{ github.event.pull_request.head.sha }} - timeout-minutes: "300" - env: - BUILD_LOCK_APP_ID: ${{ secrets.BUILD_LOCK_APP_ID }} - BUILD_LOCK_APP_PRIVATE_KEY: ${{ secrets.BUILD_LOCK_APP_PRIVATE_KEY }} - - - name: Require acquired Unity lock - if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} - shell: pwsh - run: exit 1 - - name: Run Unity Test Runner id: run_tests if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} diff --git a/docs/ops/ambiguous-release-migration.md b/docs/ops/ambiguous-release-migration.md index f9f74f84..0bc02365 100644 --- a/docs/ops/ambiguous-release-migration.md +++ b/docs/ops/ambiguous-release-migration.md @@ -132,7 +132,7 @@ RAM-64GB]` so either Windows machine can pick up any Unity job. The - [ ] In the `Ambiguous-Interactive` organization, open **Settings**, **Actions**, then **Runners**. -- [ ] Confirm at least one online self-hosted Windows runner has the labels +- [ ] Confirm at least one registered self-hosted Windows runner has the labels `self-hosted`, `Windows`, and `RAM-64GB`. - [ ] Confirm `ELI-MACHINE` retains its `fast` label for future opt-in use. - [ ] Confirm every licensed job in `.github/workflows/unity-tests.yml`, diff --git a/docs/runbooks/required-checks.md b/docs/runbooks/required-checks.md index 2b073138..7fb8eb4b 100644 --- a/docs/runbooks/required-checks.md +++ b/docs/runbooks/required-checks.md @@ -103,7 +103,7 @@ Unity CI Success `re-actors/alls-green` over `matrix-config`, `runner-preflight`, and the `unity-tests` matrix, with intentional matrix skips allowed. Do **not** require the expanded matrix job names (`Unity `), `Resolve Unity test -matrix`, or `Self-hosted runner access preflight`. When a job-level `if:` skips +matrix`, or `Self-hosted runner registration preflight`. When a job-level `if:` skips a matrix before expansion, GitHub can report only one skipped check with the literal name `Unity ${{ matrix.unity-version }} ${{ matrix.test-mode }}`, so requiring the expanded names leaves auto-merge waiting for absent checks. diff --git a/docs/runbooks/unity-runners-after-transfer.md b/docs/runbooks/unity-runners-after-transfer.md index 5405e838..6c7eb47d 100644 --- a/docs/runbooks/unity-runners-after-transfer.md +++ b/docs/runbooks/unity-runners-after-transfer.md @@ -70,7 +70,7 @@ Change the group's visibility to all repositories: 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 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. +After applying the chosen resolution, re-run the queued workflow from the Actions tab. The preflight job added to each Unity workflow validates runner registration and repository visibility from `ubuntu-latest` before any matrix entry attempts to dispatch onto self-hosted; a green preflight confirms that GitHub has a matching registered target. ## Preflight diagnostic in this repository @@ -80,17 +80,26 @@ Unity workflows in this repository run a `runner-preflight` job on `BUILD_LOCK_READER_APP_ID` and `BUILD_LOCK_READER_APP_PRIVATE_KEY` secrets. Its reader App has Metadata, Actions, and organization self-hosted runner read permission. It filters runner groups through the repository-visible API and -requires an online runner with the exact labels requested by the downstream -job. +requires a registered runner with the exact labels requested by the downstream +job. It deliberately ignores connection and busy state, so an offline or busy +registered runner passes and GitHub queues the dependent job normally. The preflight is fail closed. Missing reader credentials, an unreadable inventory, a runner-group ACL that excludes this repository, or no matching -online runner makes the workflow red before any self-hosted job is queued. Do +registered runner makes the workflow red before any self-hosted job is queued. Do not restore the retired `RUNNER_AUDIT_PAT` soft-pass path. Organization secrets avoid per-repository environment provisioning or manual approvals while the reader App remains the least-privilege inventory boundary. -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: [unstick-run.yml](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/.github/workflows/unstick-run.yml) for manual recovery and [stuck-job-watchdog.yml](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/.github/workflows/stuck-job-watchdog.yml) for the automated path. +If the preflight passes but the matrix job stays queued, first verify whether +the registered runner is intentionally offline or busy. If it is online and +idle, investigate the dispatcher bug (see +[GitHub Community Discussion #186811](https://github.com/orgs/community/discussions/186811)). +Use the recovery workflows in this repository: +[unstick-run.yml](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/.github/workflows/unstick-run.yml) +for manual recovery and +[stuck-job-watchdog.yml](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/.github/workflows/stuck-job-watchdog.yml) +for the automated path. ## PowerShell 7 prerequisite on self-hosted runners diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index b9c0ed1a..5e3563ae 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -348,6 +348,7 @@ test("every Unity lock window releases with explicit cleanup proof", () => { for (const [file, labels] of runnerLabels) { const source = fs.readFileSync(path.join(WORKFLOW_DIR, file), "utf8"); const preflight = getJobBlock(source, "runner-preflight", file); + assert.match(preflight, /\n name: Self-hosted runner registration preflight\n/, file); assert.match(preflight, /\n runs-on: ubuntu-latest\n/, file); assert.match(preflight, new RegExp(`uses: ${escapeRegExp(preflightAction)}`), file); assert.match(preflight, /reader-app-id: \$\{\{ secrets\.BUILD_LOCK_READER_APP_ID \}\}/, file); @@ -357,9 +358,36 @@ test("every Unity lock window releases with explicit cleanup proof", () => { file ); assert.match(preflight, new RegExp(`required-label-sets: '${escapeRegExp(labels)}'`), file); - assert.doesNotMatch(preflight, /RUNNER_AUDIT_PAT|Soft pass|soft-pass/i, file); + assert.doesNotMatch( + preflight, + /RUNNER_AUDIT_PAT|Soft pass|soft-pass|Require an online/i, + file + ); } + const bootstrapSource = fs.readFileSync( + path.join(WORKFLOW_DIR, "runner-bootstrap.yml"), + "utf8" + ); + const bootstrapPreflight = getJobBlock( + bootstrapSource, + "runner-preflight", + "runner-bootstrap.yml" + ); + assert.match( + bootstrapPreflight, + /\n name: Self-hosted runner registration preflight\n/ + ); + assert.match( + bootstrapPreflight, + new RegExp(`uses: ${escapeRegExp(preflightAction)}`) + ); + assert.match( + bootstrapPreflight, + /required-label-sets: '\[\["self-hosted","Windows","RAM-64GB","\$\{\{ inputs\.runner-label \}\}"\]\]'/ + ); + assert.doesNotMatch(bootstrapPreflight, /\n run:|\.status|RUNNER_AUDIT_PAT/); + for (const action of [acquire, returnLicense, classify, release, gate]) { assert.equal( workflowSources.reduce((count, source) => count + source.split(action).length - 1, 0), @@ -385,6 +413,7 @@ test("every Unity lock window releases with explicit cleanup proof", () => { const lifecycleNames = [ "Acquire organization Unity lock", "Require acquired Unity lock", + "Provision Unity Editor", licensedWorkName, "Return Unity license", "Classify Unity cleanup evidence", From 079264821ef53801ed4e7ce28257dbf72a0214fc Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 30 Jul 2026 17:25:29 +0000 Subject: [PATCH 10/22] test: keep runner contracts within JS budget --- .../__tests__/ci-aggregate-workflow.test.js | 28 ++++--------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index 5e3563ae..bb36a3cd 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -334,6 +334,10 @@ test("every Unity lock window releases with explicit cleanup proof", () => { const runnerLabels = new Map([ ["perf-numbers.yml", '[["self-hosted","Windows","RAM-64GB","fast"]]'], ["release.yml", '[["self-hosted","Windows","RAM-64GB"]]'], + [ + "runner-bootstrap.yml", + '[["self-hosted","Windows","RAM-64GB","${{ inputs.runner-label }}"]]' + ], ["unity-benchmarks.yml", '[["self-hosted","Windows","RAM-64GB"]]'], ["unity-tests.yml", '[["self-hosted","Windows","RAM-64GB"]]'] ]); @@ -363,31 +367,9 @@ test("every Unity lock window releases with explicit cleanup proof", () => { /RUNNER_AUDIT_PAT|Soft pass|soft-pass|Require an online/i, file ); + if (file === "runner-bootstrap.yml") assert.doesNotMatch(preflight, /\n run:|\.status/); } - const bootstrapSource = fs.readFileSync( - path.join(WORKFLOW_DIR, "runner-bootstrap.yml"), - "utf8" - ); - const bootstrapPreflight = getJobBlock( - bootstrapSource, - "runner-preflight", - "runner-bootstrap.yml" - ); - assert.match( - bootstrapPreflight, - /\n name: Self-hosted runner registration preflight\n/ - ); - assert.match( - bootstrapPreflight, - new RegExp(`uses: ${escapeRegExp(preflightAction)}`) - ); - assert.match( - bootstrapPreflight, - /required-label-sets: '\[\["self-hosted","Windows","RAM-64GB","\$\{\{ inputs\.runner-label \}\}"\]\]'/ - ); - assert.doesNotMatch(bootstrapPreflight, /\n run:|\.status|RUNNER_AUDIT_PAT/); - for (const action of [acquire, returnLicense, classify, release, gate]) { assert.equal( workflowSources.reduce((count, source) => count + source.split(action).length - 1, 0), From 774260e8757179dd18e57893c7fcf6d6814cacfd Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 30 Jul 2026 17:30:06 +0000 Subject: [PATCH 11/22] ci: preflight bootstrap shared runner labels --- .github/workflows/runner-bootstrap.yml | 4 ++-- scripts/__tests__/ci-aggregate-workflow.test.js | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/runner-bootstrap.yml b/.github/workflows/runner-bootstrap.yml index 0df45afd..37e63c7f 100644 --- a/.github/workflows/runner-bootstrap.yml +++ b/.github/workflows/runner-bootstrap.yml @@ -107,12 +107,12 @@ jobs: group: ${{ github.workflow }}-runner-preflight-${{ github.ref }} cancel-in-progress: true steps: - - name: Require the selected Unity runner to be registered + - name: Require a registered Windows Unity runner uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/check-unity-runner-availability@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: reader-app-id: ${{ secrets.BUILD_LOCK_READER_APP_ID }} reader-app-private-key: ${{ secrets.BUILD_LOCK_READER_APP_PRIVATE_KEY }} - required-label-sets: '[["self-hosted","Windows","RAM-64GB","${{ inputs.runner-label }}"]]' + required-label-sets: '[["self-hosted","Windows","RAM-64GB"]]' bootstrap: # F9: surface the mode (Audit vs Maintenance) in the job display name so diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index bb36a3cd..fabdd668 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -336,7 +336,7 @@ test("every Unity lock window releases with explicit cleanup proof", () => { ["release.yml", '[["self-hosted","Windows","RAM-64GB"]]'], [ "runner-bootstrap.yml", - '[["self-hosted","Windows","RAM-64GB","${{ inputs.runner-label }}"]]' + '[["self-hosted","Windows","RAM-64GB"]]' ], ["unity-benchmarks.yml", '[["self-hosted","Windows","RAM-64GB"]]'], ["unity-tests.yml", '[["self-hosted","Windows","RAM-64GB"]]'] @@ -367,7 +367,8 @@ test("every Unity lock window releases with explicit cleanup proof", () => { /RUNNER_AUDIT_PAT|Soft pass|soft-pass|Require an online/i, file ); - if (file === "runner-bootstrap.yml") assert.doesNotMatch(preflight, /\n run:|\.status/); + if (file === "runner-bootstrap.yml") + assert.doesNotMatch(preflight, /\n run:|\.status|\$\{\{ inputs\.runner-label/); } for (const action of [acquire, returnLicense, classify, release, gate]) { From ae8d957632e9cc2afd00639a1b4ed0e5bd1222e7 Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 30 Jul 2026 17:47:20 +0000 Subject: [PATCH 12/22] ci: distinguish Unity CLI activity from silence --- .github/workflows/ci.yml | 5 + docs/images/inspector-overlay/README.md | 32 +- .../session-177-safe-editor-capture-proof.md | 127 +++++-- .../__tests__/ci-aggregate-workflow.test.js | 161 +++------ .../__tests__/test-unity-editor-heartbeat.ps1 | 261 ++++++++++++++ .../test-unity-editor-heartbeat.ps1.meta | 7 + scripts/unity/ensure-editor.ps1 | 338 +++++++++++------- 7 files changed, 672 insertions(+), 259 deletions(-) create mode 100644 scripts/__tests__/test-unity-editor-heartbeat.ps1 create mode 100644 scripts/__tests__/test-unity-editor-heartbeat.ps1.meta diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b48d2834..fe7e91ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -772,6 +772,11 @@ jobs: shell: pwsh run: ./scripts/__tests__/test-unity-license-activation-retry.ps1 -VerboseOutput + - name: Test Unity editor heartbeat guards + if: ${{ needs.changes.outputs.scripts != 'false' }} + shell: pwsh + run: ./scripts/__tests__/test-unity-editor-heartbeat.ps1 -VerboseOutput + - name: Run validators if: ${{ needs.changes.outputs.scripts != 'false' && matrix.os == 'ubuntu-latest' }} shell: bash diff --git a/docs/images/inspector-overlay/README.md b/docs/images/inspector-overlay/README.md index d911c6a3..ee7fbe9e 100644 --- a/docs/images/inspector-overlay/README.md +++ b/docs/images/inspector-overlay/README.md @@ -35,8 +35,8 @@ automatically. ## Automation status Unity MCP scene/camera captures are suitable for scene-layout checks, but the -current host setup has not yet proven a complete editor-window screenshot path -for these docs targets. On 2026-07-03, +current host setup has not yet proven a complete acceptance-ready editor-window +screenshot path for these docs targets. On 2026-07-03, `UnityEditorInternal.InternalEditorUtility.ReadScreenPixel` probes captured the visible VS Code desktop instead of Unity editor windows, even when MCP-reported Unity window coordinates were used. A later synchronous Win32/GDI `PrintWindow` @@ -53,6 +53,34 @@ Inspector overlay PNGs until the actual target artifact is Personal/light-theme, cropped per this manifest, visually inspected, and Unity has a clean post-capture console and editor-window list. +On 2026-07-30, an offscreen panel-render experiment established the provisional +desktop-independent method: + +1. Create a temporary `HideAndDontSave` window containing the real shipped view, + or a transient Inspector containing the real component editor. +1. Give the panel a fixed size. Reflect inherited `EditorPanel` methods with + instance, public, and non-public binding flags; do not use `DeclaredOnly`. +1. Call `ValidateLayout()`, repaint with `EventType.Repaint`, then call + `Render()` while a temporary linear `RenderTexture` is active. +1. Preserve and restore the active render target, viewport, `GL.sRGBWrite`, + selection, and window state. In this linear-color project, + `RenderTextureReadWrite.Linear` with `GL.sRGBWrite = false` matched the real + panel colors. +1. Read only that temporary render target into an RGB24 `Texture2D`, encode the + PNG, and verify PNG color type 2 (truecolor without alpha). The staging proofs + used RGBA and therefore do not satisfy this manifest's 24-bit requirement. +1. Compare the Console error set and editor-window list before and after + capture. Do not clear the Console to hide capture diagnostics or erase user + logs; abort acceptance if the capture adds an error. + +This method produced a clean, visually correct Message Monitor staging image. +The Inspector staging image was visually correct but emitted +`EditorGUIUtility.AddCursorRect called outside an editor OnGUI` while its IMGUI +body rendered. No reusable capture helper is retained yet. Treat the Inspector +method as experimental until a retained helper produces the frame without new +diagnostics. Native menu cascades and combined Hierarchy/Inspector frames still +require manual host capture or an explicit scope change. + Do not switch editor skins as part of automation. Start from an editor that is already in Personal/light theme, record `EditorGUIUtility.isProSkin` and the `UserSkin` editor preference before capture, and abort if the editor is not diff --git a/progress/session-177-safe-editor-capture-proof.md b/progress/session-177-safe-editor-capture-proof.md index 6af109ac..cb625872 100644 --- a/progress/session-177-safe-editor-capture-proof.md +++ b/progress/session-177-safe-editor-capture-proof.md @@ -9,16 +9,21 @@ PR: **#316** The remaining editor-tooling screenshot work is narrower than the prior plan recorded. Unity 6000.4.6f1 can render a genuine Editor panel into an offscreen `RenderTexture`, so the devcontainer does not need a desktop screenshot API. The -method produced visually inspectable proof for both acceptance surfaces: +method produced visually inspectable staging evidence for both target surfaces: -- the real Message Monitor with live package UI and data; +- the real Message Monitor with shipped package UI and discovered component + rows; - a real Inspector containing `MessagingComponentEditor` and the current Message subscriptions section. The host reports `EditorGUIUtility.isProSkin == true` and `UserSkin == 1`. The proofs are therefore dark-theme experiments, not documentation replacements. The manifest requests Unity 2022.3 LTS, while the configured host is 6000.4.6f1. -The remaining WS-7.3 work is to resolve that version choice, select Personal/light +The Monitor mechanism completed without a capture diagnostic. The Inspector +render emitted an IMGUI cursor diagnostic, both proofs are RGBA rather than the +manifest's required 24-bit RGB, and no reusable capture helper is retained. The +remaining WS-7.3 work is to make the Inspector path repeatable and +diagnostic-free, emit RGB24, resolve the version choice, select Personal/light through the Unity UI, capture the complete set, inspect it, and replace the tracked images and manifest. @@ -44,13 +49,14 @@ panel render avoids both constraints: washed-out grays; the linear rerun visually matched the real dark panels and amber borders. 1. Close the temporary windows, destroy transient objects, restore selection and - render state, and clear the Console. + render state, and record any new Console diagnostics without clearing them. This reads only the render target created for the experiment. It does not read the desktop, call `ReadScreenPixel`, invoke `PrintWindow`, move a window onscreen, or change the Unity skin. -The safe path produced two saved dark proof artifacts: +The desktop-independent staging experiment produced two saved dark proof +artifacts: | Surface | Artifact | Size | SHA-256 | | --- | --- | ---: | --- | @@ -78,11 +84,12 @@ delegates to `InternalEditorUtility.ReadScreenPixel`, so these files are unsafe historical staging evidence, not acceptance or RenderTexture evidence. The render-target, `GL.sRGBWrite`, active-target, selection, and window state were -restored in cleanup. The Inspector render emitted transient +restored in cleanup. The Inspector render emitted `EditorGUIUtility.AddCursorRect called outside an editor OnGUI` diagnostics while -painting its IMGUI body into the offscreen panel; after capture the Console was -cleared and a fresh error query returned zero entries. Unity was idle, no -temporary Monitor or Inspector remained, and selection was clear. +painting its IMGUI body into the offscreen panel. The Console was cleared after +the experiment and a fresh error query returned zero entries, but clearing is +not acceptance evidence and must not be part of the final workflow. Unity was +idle, no temporary Monitor or Inspector remained, and selection was clear. Exactly the seven standard windows remained: MainToolbar, Project, Inspector, Hierarchy, Scene, Game, and Console. @@ -92,9 +99,10 @@ The plan and issue #314 prohibit both `ReadScreenPixel` and `PrintWindow`, but t repository test guarded only the first API. Added `PrintWindow` to the blocked capture primitives in `scripts/__tests__/design-system-dumps.test.js`. -The targeted test passed before and after the change. A temporary C# probe that -contained `PrintWindow` made the same test fail with the expected path and token; -removing the probe restored green: +The targeted test was green before the change because it did not yet check +`PrintWindow`. After adding the token, a temporary C# probe containing +`PrintWindow` made the test fail with the expected path and token; removing the +probe restored green: ```text tests 3 @@ -126,22 +134,99 @@ while repairing a missing Windows IL2CPP module: another process held the manage Editor directory, three quarantine attempts failed, and the cleanup gate correctly failed closed. -A controlled rerun of only that failed job was started before changing the PR -head. Its result distinguishes transient runner state from a repeatable -provisioning defect. The branch will then be synchronized with current `master` -so it carries the session 176 prototype-exclusion guard before a full matrix is -requested. +A controlled rerun of only that failed job completed before changing the PR +head. Retry job `90935151437` reproduced the defect: + +- `unity install 6000.3.16f1 --accept-eula -m windows-il2cpp` emitted 7,029 + repeated progress lines at exactly 50 percent and made no progress-triple + advance for 1,800 seconds; +- the heartbeat guard requested process-tree termination and returned sentinel + exit 125 without separately proving that every descendant had exited; +- `Unity.exe` was resolvable afterward, but Windows IL2CPP was absent and the + Unity CLI reported that the editor had no manageable modules; +- the atomic in-place reinstall reported that the editor was already installed, + without supplying the module; +- uninstall plus all three bounded quarantine attempts then failed because a + process still held + `E:\actions-runner\_tool\u6-v3\6000.3.16f1\Editor`; +- the version-scoped stale-process sweep matched zero processes, so the script + correctly surfaced the runner-operator `handle64.exe`/manual-delete + remediation and the cleanup gate failed closed because the build lock had + never been acquired. + +The repeat is evidence of a persistent provisioning defect, not a transient +failure or a test failure. A read-only follow-up from the connected Unity host +could not inspect the failed cache directly because the host does not expose the +runner's `E:` tool-cache volume. + +The provisioning guard used an invalid classification signal. The Unity beta +CLI was still emitting one progress line about every quarter-second when the +guard killed it, and run `26701943540` had also emitted the same unchanged +50-percent triple while laying down a resolvable editor. An unchanged +`(pct, phase, msg)` therefore cannot distinguish a hung install from a long, +active operation. + +`Invoke-UnityCliCaptureWithTimeout` now resets its heartbeat on every +stdout/stderr line and retains the progress triple only for human-readable +notices. A genuinely silent child still receives sentinel exit 125 after the +profile-aware idle threshold; a child that remains noisy forever is still +bounded by the independent 2,700-second wall-clock sentinel 124. Both guards use +a monotonic `Stopwatch`. Before returning either sentinel, the wrapper requests +tree termination and confirms the direct child exited. A direct-child fallback +is not treated as proof of tree termination. If the direct child exited before +the tree request, if `Kill(true)` is unavailable or fails, or if the child cannot +be reaped after a second bounded termination attempt, the wrapper throws a +marked, non-retryable process-safety error. Provisioning cannot start another +attempt while a descendant may still hold the editor tree. A real rerun is +still required to learn whether the 6000.3 install completes under the new +policy; the prior logs do not prove that the termination request caused the +partial editor or the unidentified directory lock. + +A hermetic AST-extracted PowerShell probe supplied the initial red-green +evidence. Before the fix, a fake installer emitting eight identical progress +lines at 200 ms intervals was killed after one second with exit 125 after only +six lines. After the fix, all eight lines completed with exit 0 and +`StallKilled == false`; a second fake installer that emitted no output was still +killed after one second with exit 125 and `StallKilled == true`. + +The experiment is now retained in +`scripts/__tests__/test-unity-editor-heartbeat.ps1`. Its 32 assertions exercise +stdout and stderr activity, the environment override, monotonic periodic +notices, direct-child exit confirmation, descendant tree termination, the noisy +wall deadline, both outcomes of the second bounded reap, and the actual +fail-closed producer/consumer path for a quick-exit parent whose live orphan +holds inherited pipes. CI executes the test on Linux, macOS, and Windows. + +The branch was then merged with current `master` at `645cde05`, bringing in the +session 176 prototype-exclusion guard and the latest performance-number update. +That integration raised JavaScript source LOC to 17,565 against the 17,500 +budget. A table-driven consolidation in +`scripts/__tests__/ci-aggregate-workflow.test.js` preserves the workflow +contracts while removing repeated assertion scaffolding and reduces the total +to 17,498. The focused aggregate-workflow suite passes all 18 tests, and the +heartbeat probe passes all 32 assertions. The final full Node and repository +validation reruns remain pending after these changes. The full merged +`WallstopStudios.DxMessaging.Tests.Editor` assembly then passed 549 tests with +zero failures in 12.3873838 seconds through `DxMcpTestRunner`; the result is +retained at +`.artifacts/unity-mcp/session-177-full-editmode-after-merge.json`. ## Remaining work -- Finish the controlled Unity 6000.3 standalone rerun and act on its evidence. -- Synchronize PR #316 with current `master`, validate, push, and obtain current - reviews and required checks. +- Commit the current provisioning, regression-test, documentation, and + integration compaction changes; validate, push, and obtain current reviews and + required checks. - Switch the host to Personal/light through the Unity UI. - Resolve the manifest's Unity 2022.3 LTS requirement versus the configured 6000.4.6f1 host. +- Retain a reusable render-target capture helper, eliminate the Inspector IMGUI + cursor diagnostic without clearing the Console, and emit RGB24 PNGs. +- Validate the helper on Project Settings and Flow Graph in addition to the + Monitor and Inspector targets. - Repeat the offscreen Inspector and Message Monitor captures. Capture the native menu cascade and combined Hierarchy/Inspector frames manually, or explicitly revise their scope. - Inspect every final image, then update the documentation screenshots and - manifest together. + `docs/guides/inspector-overlay.md` and + `docs/images/inspector-overlay/README.md` manifest together. Convert the + guide's three legacy `!!!` admonitions when editing it. diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index df4d9b29..632bc1b2 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -410,49 +410,32 @@ test("every Unity lock window releases with explicit cleanup proof", () => { const classify = `uses: ${LOCK_ACTION_PREFIX}classify-unity-cleanup-evidence@${CLEANUP_POLICY_SHA}`; const release = `uses: ${LOCK_ACTION_PREFIX}release-build-lock@${CLEANUP_POLICY_SHA}`; const gate = `uses: ${LOCK_ACTION_PREFIX}require-confirmed-unity-cleanup@${CLEANUP_POLICY_SHA}`; - const runnerLabels = new Map([ - ["perf-numbers.yml", '[["self-hosted","Windows","RAM-64GB","fast"]]'], - ["release.yml", '[["self-hosted","Windows","RAM-64GB"]]'], - ["unity-benchmarks.yml", '[["self-hosted","Windows","RAM-64GB"]]'], - ["unity-tests.yml", '[["self-hosted","Windows","RAM-64GB"]]'] - ]); - const preflightAction = - `${LOCK_ACTION_PREFIX}check-unity-runner-availability@${LOCK_ACTION_SHA}` + - LOCK_ACTION_PIN.comment; - const workflowSources = fs - .readdirSync(WORKFLOW_DIR) - .filter((file) => /\.ya?ml$/.test(file)) - .map((file) => fs.readFileSync(path.join(WORKFLOW_DIR, file), "utf8")); + // prettier-ignore + const runnerLabels = new Map([["perf-numbers.yml", '[["self-hosted","Windows","RAM-64GB","fast"]]'], ["release.yml", '[["self-hosted","Windows","RAM-64GB"]]'], ["unity-benchmarks.yml", '[["self-hosted","Windows","RAM-64GB"]]'], ["unity-tests.yml", '[["self-hosted","Windows","RAM-64GB"]]']]); + // prettier-ignore + const preflightAction = `${LOCK_ACTION_PREFIX}check-unity-runner-availability@${LOCK_ACTION_SHA}` + LOCK_ACTION_PIN.comment; + const readWorkflow = (file) => fs.readFileSync(path.join(WORKFLOW_DIR, file), "utf8"); + // prettier-ignore + const workflowSources = fs.readdirSync(WORKFLOW_DIR).filter((file) => /\.ya?ml$/.test(file)).map(readWorkflow); for (const [file, labels] of runnerLabels) { - const source = fs.readFileSync(path.join(WORKFLOW_DIR, file), "utf8"); + const source = readWorkflow(file); const preflight = getJobBlock(source, "runner-preflight", file); - assert.match(preflight, /\n runs-on: ubuntu-latest\n/, file); - assert.match(preflight, new RegExp(`uses: ${escapeRegExp(preflightAction)}`), file); - assert.match(preflight, /reader-app-id: \$\{\{ secrets\.BUILD_LOCK_READER_APP_ID \}\}/, file); - assert.match( - preflight, - /reader-app-private-key: \$\{\{ secrets\.BUILD_LOCK_READER_APP_PRIVATE_KEY \}\}/, - file - ); - assert.match(preflight, new RegExp(`required-label-sets: '${escapeRegExp(labels)}'`), file); + // prettier-ignore + const contracts = [/\n runs-on: ubuntu-latest\n/, new RegExp(`uses: ${escapeRegExp(preflightAction)}`), /reader-app-id: \$\{\{ secrets\.BUILD_LOCK_READER_APP_ID \}\}/, /reader-app-private-key: \$\{\{ secrets\.BUILD_LOCK_READER_APP_PRIVATE_KEY \}\}/, new RegExp(`required-label-sets: '${escapeRegExp(labels)}'`)]; + for (const contract of contracts) assert.match(preflight, contract, file); assert.doesNotMatch(preflight, /RUNNER_AUDIT_PAT|Soft pass|soft-pass/i, file); } for (const action of [acquire, returnLicense, classify, release, gate]) { - assert.equal( - workflowSources.reduce((count, source) => count + source.split(action).length - 1, 0), - UNITY_LOCK_WINDOWS.length, - action - ); + const count = workflowSources.reduce((sum, source) => sum + source.split(action).length - 1, 0); + assert.equal(count, UNITY_LOCK_WINDOWS.length, action); } for (const [file, jobId, licensedWorkName, emptyAware] of UNITY_LOCK_WINDOWS) { const label = `${file}:${jobId}`; - const licensedCondition = - `${emptyAware ? "steps\\.compute\\.outputs\\.is-empty != 'true' && " : ""}` + - "steps\\.acquire_lock\\.outputs\\.acquired == 'true'"; - const source = fs.readFileSync(path.join(WORKFLOW_DIR, file), "utf8"); + const licensedCondition = `${emptyAware ? "steps\\.compute\\.outputs\\.is-empty != 'true' && " : ""}steps\\.acquire_lock\\.outputs\\.acquired == 'true'`; + const source = readWorkflow(file); const job = getJobBlock(source, jobId, file); if (["perf-numbers.yml", "unity-benchmarks.yml", "unity-tests.yml"].includes(file)) { assert.match(job, /\n fail-fast: false\n max-parallel: 1\n/, `${label} fairness`); @@ -461,25 +444,13 @@ test("every Unity lock window releases with explicit cleanup proof", () => { assert.equal(job.split(action).length - 1, 1, `${label}: ${action}`); } - const lifecycleNames = [ - "Acquire organization Unity lock", - "Require acquired Unity lock", - licensedWorkName, - "Return Unity license", - "Classify Unity cleanup evidence", - "Release organization Unity lock", - "Require confirmed Unity cleanup" - ]; + // prettier-ignore + const lifecycleNames = ["Acquire organization Unity lock", "Require acquired Unity lock", licensedWorkName, "Return Unity license", "Classify Unity cleanup evidence", "Release organization Unity lock", "Require confirmed Unity cleanup"]; const positions = lifecycleNames.map((name) => job.indexOf(` - name: ${name}`)); - assert.ok( - positions.every((position) => position >= 0), - `${label} lifecycle steps must all exist` - ); - assert.deepEqual( - positions, - [...positions].sort((a, b) => a - b), - `${label} lifecycle order` - ); + const sortedPositions = [...positions].sort((a, b) => a - b); + // prettier-ignore + assert.ok(positions.every((position) => position >= 0), `${label} lifecycle steps must all exist`); + assert.deepEqual(positions, sortedPositions, `${label} lifecycle order`); const acquireStep = getStepBlock(job, "Acquire organization Unity lock"); const provisionStep = getStepBlock(job, "Provision Unity Editor"); @@ -490,79 +461,41 @@ test("every Unity lock window releases with explicit cleanup proof", () => { const releaseStep = getStepBlock(job, "Release organization Unity lock"); const gateStep = getStepBlock(job, "Require confirmed Unity cleanup"); - assert.match(acquireStep, /\n id: acquire_lock\n/); - assert.match( - provisionStep, - /-InstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, - `${label}: central cleanup and provisioning must use the same trusted editor root` - ); - assert.doesNotMatch( - provisionStep, - /-RequireHealthyExisting/, - `${label}: provisioning must be able to populate the trusted editor root` - ); - assert.match( - requireStep, - /\n if: \$\{\{ steps\.acquire_lock\.outputs\.acquired != 'true' \}\}\n[\s\S]*\n run: exit 1\n/ - ); - assert.match( - workStep, - new RegExp(`\\n if: \\$\\{\\{ ${licensedCondition} \\}\\}\\n`), - label - ); - if (file === "unity-tests.yml") { - assert.match( - workStep, - /-UnityInstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, - `${label}: licensed work and central return must use the same trusted editor root` - ); - } - assert.match( - workStep, - /-LicenseReturnOwner Central/, - `${label}: the trusted central action must own the post-activation return` - ); - assert.match( - returnStep, - /\n id: return_unity_license\n if: \$\{\{ always\(\) && steps\.acquire_lock\.outputs\.acquired == 'true' \}\}\n/ - ); + // prettier-ignore + const contracts = [ + [acquireStep, /\n id: acquire_lock\n/], + [provisionStep, /-InstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, `${label}: central cleanup and provisioning must use the same trusted editor root`], + [requireStep, /\n if: \$\{\{ steps\.acquire_lock\.outputs\.acquired != 'true' \}\}\n[\s\S]*\n run: exit 1\n/], + [workStep, new RegExp(`\\n if: \\$\\{\\{ ${licensedCondition} \\}\\}\\n`), label], + [workStep, /-LicenseReturnOwner Central/, `${label}: the trusted central action must own the post-activation return`], + [returnStep, /\n id: return_unity_license\n if: \$\{\{ always\(\) && steps\.acquire_lock\.outputs\.acquired == 'true' \}\}\n/], + [classifyStep, /\n id: cleanup_classification\n if: \$\{\{ always\(\) && steps\.acquire_lock\.outputs\.acquired == 'true' \}\}\n/], + [classifyStep, /return-log-digest: \$\{\{ steps\.return_unity_license\.outputs\.return-log-digest \}\}/], + [releaseStep, /\n id: release_unity_lock\n if: always\(\)\n/], + [releaseStep, /resource-cleanup-status: \$\{\{ steps\.cleanup_classification\.outputs\.resource-cleanup-status \}\}/], + [gateStep, /\n if: always\(\)\n/], + [gateStep, /classification-complete: \$\{\{ steps\.cleanup_classification\.outputs\.classification-complete \}\}/], + [gateStep, /release-outcome: \$\{\{ steps\.release_unity_lock\.outcome \}\}/] + ]; + // prettier-ignore + if (file === "unity-tests.yml") contracts.push([workStep, /-UnityInstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, `${label}: licensed work and central return must use the same trusted editor root`]); + for (const [actual, contract, message] of contracts) assert.match(actual, contract, message); + + // prettier-ignore + assert.doesNotMatch(provisionStep, /-RequireHealthyExisting/, `${label}: provisioning must be able to populate the trusted editor root`); assert.doesNotMatch(returnStep, /continue-on-error:/); - assert.match( - classifyStep, - /\n id: cleanup_classification\n if: \$\{\{ always\(\) && steps\.acquire_lock\.outputs\.acquired == 'true' \}\}\n/ - ); - assert.match( - classifyStep, - /return-log-digest: \$\{\{ steps\.return_unity_license\.outputs\.return-log-digest \}\}/ - ); - assert.match(releaseStep, /\n id: release_unity_lock\n if: always\(\)\n/); - assert.match( - releaseStep, - /resource-cleanup-status: \$\{\{ steps\.cleanup_classification\.outputs\.resource-cleanup-status \}\}/ - ); - assert.match(gateStep, /\n if: always\(\)\n/); - assert.match( - gateStep, - /classification-complete: \$\{\{ steps\.cleanup_classification\.outputs\.classification-complete \}\}/ - ); - assert.match(gateStep, /release-outcome: \$\{\{ steps\.release_unity_lock\.outcome \}\}/); const acquireHolder = /holder-id-suffix: (.+)\n/.exec(acquireStep); const releaseHolder = /holder-id-suffix: (.+)\n/.exec(releaseStep); const acquireRunner = /runner-id: (.+)\n/.exec(acquireStep); const releaseRunner = /runner-id: (.+)\n/.exec(releaseStep); - assert.ok(acquireHolder, `${label} acquire holder identity`); - assert.ok(releaseHolder, `${label} release holder identity`); - assert.ok(acquireRunner, `${label} acquire runner identity`); - assert.ok(releaseRunner, `${label} release runner identity`); + // prettier-ignore + for (const [identity, name] of [[acquireHolder, "acquire holder"], [releaseHolder, "release holder"], [acquireRunner, "acquire runner"], [releaseRunner, "release runner"]]) assert.ok(identity, `${label} ${name} identity`); assert.equal(releaseHolder?.[1], acquireHolder?.[1], `${label} holder identity`); assert.equal(releaseRunner?.[1], acquireRunner?.[1], `${label} runner identity`); - assert.doesNotMatch( - job, - /\n environment:/, - `${label} must not require environment approval` - ); + // prettier-ignore + assert.doesNotMatch(job, /\n environment:/, `${label} must not require environment approval`); assert.doesNotMatch(job, /Delete private Unity cleanup evidence/, label); } }); diff --git a/scripts/__tests__/test-unity-editor-heartbeat.ps1 b/scripts/__tests__/test-unity-editor-heartbeat.ps1 new file mode 100644 index 00000000..d767e9c1 --- /dev/null +++ b/scripts/__tests__/test-unity-editor-heartbeat.ps1 @@ -0,0 +1,261 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Behavioral tests for the Unity CLI heartbeat and wall-clock guards. + +.DESCRIPTION + Extracts the real timeout runner from ensure-editor.ps1 without executing + the script's provisioning flow. Child pwsh processes model the four + liveness states that CI must distinguish: + + - repeated, byte-identical progress remains alive and completes; + - a silent child is killed by the heartbeat sentinel; + - a noisy child that never finishes is killed by the wall-clock sentinel; + - a quick-exit parent with a live orphan fails closed without retry. +#> +[CmdletBinding()] +param( + [switch]$VerboseOutput +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptRoot = Split-Path -Parent $PSScriptRoot +$target = Join-Path $scriptRoot 'unity/ensure-editor.ps1' +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $target, + [ref]$tokens, + [ref]$errors +) +if (@($errors).Count -gt 0) { + throw "ensure-editor.ps1 has parse errors: $(@($errors.Message) -join '; ')" +} + +$functionNames = @( + 'Invoke-WithRetry', + 'ConvertTo-ProcessArgumentLine', + 'Get-CliProgressTriple', + 'Get-LastCliProgressMessage', + 'Get-CollapsedCliOutputTail', + 'Get-EnsureEditorProgressStallSeconds', + 'Get-EnsureEditorProgressNoticeIntervalSeconds', + 'Confirm-UnityCliDirectChildExit', + 'Invoke-UnityCliCaptureWithTimeout' +) +foreach ($name in $functionNames) { + $definition = $ast.Find( + { + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $name + }, + $true + ) + if (-not $definition) { + throw "Function '$name' was not found in ensure-editor.ps1." + } + Invoke-Expression $definition.Extent.Text +} + +$passed = 0 +$failed = 0 +function Assert-That { + param([string]$Description, [bool]$Condition) + if ($Condition) { + if ($VerboseOutput) { + Write-Host " PASS: $Description" + } + $script:passed++ + return + } + Write-Host " FAIL: $Description" + $script:failed++ +} + +function ConvertTo-EncodedCommand { + param([Parameter(Mandatory = $true)][string]$Code) + return [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Code)) +} + +function New-FakeTerminationProcess { + param([bool]$ExitOnSecondWait) + + $fake = [pscustomobject]@{ + WaitCalls = 0 + KillCalls = 0 + HasExited = $false + ExitOnSecondWait = $ExitOnSecondWait + } + $fake | Add-Member -MemberType ScriptMethod -Name WaitForExit -Value { + param([int]$Milliseconds) + $this.WaitCalls++ + if ($this.ExitOnSecondWait -and $this.WaitCalls -ge 2) { + $this.HasExited = $true + return $true + } + return $false + } + $fake | Add-Member -MemberType ScriptMethod -Name Kill -Value { + param([bool]$EntireProcessTree) + $this.KillCalls++ + } + return $fake +} + +$script:UnityCliPath = (Get-Command pwsh -ErrorAction Stop).Source +$savedNoticeInterval = $env:DXM_ENSURE_EDITOR_PROGRESS_NOTICE_INTERVAL_SECONDS +$savedStallSeconds = $env:DXM_ENSURE_EDITOR_PROGRESS_STALL_SECONDS +try { + $env:DXM_ENSURE_EDITOR_PROGRESS_NOTICE_INTERVAL_SECONDS = '1' + + $noisyFinite = @' +1..7 | ForEach-Object { + $line = '{"type":"progress","pct":50,"msg":"Installing Unity...","phase":"install"}' + if ($_ % 2 -eq 0) { + [Console]::Error.WriteLine($line) + } else { + Write-Output $line + } + Start-Sleep -Milliseconds 500 +} +'@ + $script:noisyFiniteResult = $null + $noticeOutput = @(& { + $script:noisyFiniteResult = Invoke-UnityCliCaptureWithTimeout ` + -Arguments @('-NoLogo', '-NoProfile', '-EncodedCommand', (ConvertTo-EncodedCommand $noisyFinite)) ` + -TimeoutSeconds 10 ` + -StallSeconds 3 + } 6>&1) + $result = $script:noisyFiniteResult + $noticeText = (@($noticeOutput | ForEach-Object { [string]$_ }) -join "`n") + Assert-That 'repeated stdout/stderr progress completes successfully' $result.Success + Assert-That 'repeated stdout/stderr progress preserves native exit 0' ($result.ExitCode -eq 0) + Assert-That 'repeated stdout/stderr progress is not heartbeat-killed' (-not $result.StallKilled) + Assert-That 'repeated stdout/stderr progress is not wall-clock-killed' (-not $result.TimedOutWallClock) + Assert-That 'repeated stdout/stderr progress confirms direct child exit' $result.DirectChildExited + Assert-That 'every repeated stdout/stderr progress line is captured' (@($result.Output).Count -eq 7) + Assert-That 'periodic notice reports monotonic idle elapsed time' ($noticeText -match 'install heartbeat:.*idleElapsed=\d+s') + + $env:DXM_ENSURE_EDITOR_PROGRESS_NOTICE_INTERVAL_SECONDS = '0' + $env:DXM_ENSURE_EDITOR_PROGRESS_STALL_SECONDS = '1' + $silent = 'Start-Sleep -Seconds 10' + $result = Invoke-UnityCliCaptureWithTimeout ` + -Arguments @('-NoLogo', '-NoProfile', '-EncodedCommand', (ConvertTo-EncodedCommand $silent)) ` + -TimeoutSeconds 10 + Assert-That 'silent child fails' (-not $result.Success) + Assert-That 'silent child receives heartbeat sentinel 125' ($result.ExitCode -eq 125) + Assert-That 'silent child is attributed to heartbeat' $result.StallKilled + Assert-That 'silent child is not attributed to wall clock' (-not $result.TimedOutWallClock) + Assert-That 'silent child exit is confirmed' $result.DirectChildExited + + $noisyForever = @' +while ($true) { + Write-Output 'still alive' + Start-Sleep -Milliseconds 100 +} +'@ + $result = Invoke-UnityCliCaptureWithTimeout ` + -Arguments @('-NoLogo', '-NoProfile', '-EncodedCommand', (ConvertTo-EncodedCommand $noisyForever)) ` + -TimeoutSeconds 1 ` + -StallSeconds 5 + Assert-That 'endless noisy child fails' (-not $result.Success) + Assert-That 'endless noisy child receives wall-clock sentinel 124' ($result.ExitCode -eq 124) + Assert-That 'endless noisy child is not attributed to heartbeat' (-not $result.StallKilled) + Assert-That 'endless noisy child is attributed to wall clock' $result.TimedOutWallClock + Assert-That 'endless noisy child exit is confirmed' $result.DirectChildExited + Assert-That 'endless noisy child emitted activity before its deadline' (@($result.Output).Count -gt 0) + + $descendantCode = ConvertTo-EncodedCommand 'Start-Sleep -Seconds 10' + $pwshPath = $script:UnityCliPath.Replace("'", "''") + $descendantParent = @" +`$child = Start-Process -FilePath '$pwshPath' -ArgumentList @('-NoLogo', '-NoProfile', '-EncodedCommand', '$descendantCode') -PassThru +Write-Output "descendant=`$(`$child.Id)" +Start-Sleep -Seconds 10 +"@ + $result = Invoke-UnityCliCaptureWithTimeout ` + -Arguments @('-NoLogo', '-NoProfile', '-EncodedCommand', (ConvertTo-EncodedCommand $descendantParent)) ` + -TimeoutSeconds 10 ` + -StallSeconds 1 + $descendantLine = @($result.Output | Where-Object { $_ -match '^descendant=\d+$' } | Select-Object -First 1) + $descendantId = if ($descendantLine.Count -eq 1) { + [int]($descendantLine[0] -replace '^descendant=', '') + } else { + 0 + } + Assert-That 'tree probe reaches heartbeat sentinel 125' ($result.ExitCode -eq 125) + Assert-That 'tree probe captures the descendant process id' ($descendantId -gt 0) + Assert-That 'tree probe confirms direct child exit' $result.DirectChildExited + Assert-That 'tree termination removes the descendant' ( + $descendantId -gt 0 -and $null -eq (Get-Process -Id $descendantId -ErrorAction SilentlyContinue) + ) + + $secondAttemptSuccess = New-FakeTerminationProcess -ExitOnSecondWait $true + $confirmation = Confirm-UnityCliDirectChildExit -Process $secondAttemptSuccess + Assert-That 'second reap path performs two waits and one tree request' ( + $secondAttemptSuccess.WaitCalls -eq 2 -and $secondAttemptSuccess.KillCalls -eq 1 + ) + Assert-That 'second reap path can confirm the direct child exit' $confirmation.DirectChildExited + Assert-That 'successful second reap remains retry-safe' (-not $confirmation.TerminationUnconfirmed) + + $secondAttemptFailure = New-FakeTerminationProcess -ExitOnSecondWait $false + $confirmation = Confirm-UnityCliDirectChildExit -Process $secondAttemptFailure + Assert-That 'exhausted second reap performs two waits and one tree request' ( + $secondAttemptFailure.WaitCalls -eq 2 -and $secondAttemptFailure.KillCalls -eq 1 + ) + Assert-That 'exhausted second reap leaves direct child unconfirmed' (-not $confirmation.DirectChildExited) + Assert-That 'exhausted second reap fails closed' $confirmation.TerminationUnconfirmed + + $orphanPidPath = Join-Path ([IO.Path]::GetTempPath()) "dxm-heartbeat-orphan-$([Guid]::NewGuid().ToString('N')).pid" + $orphanPidLiteral = $orphanPidPath.Replace("'", "''") + $orphanParent = @" +`$child = Start-Process -FilePath '$pwshPath' -ArgumentList @('-NoLogo', '-NoProfile', '-EncodedCommand', '$descendantCode') -PassThru +[IO.File]::WriteAllText('$orphanPidLiteral', [string]`$child.Id) +"@ + $orphanAttempts = 0 + $orphanSafetyErrorEscaped = $false + $orphanSafetyMarker = $false + $orphanId = 0 + $orphanWasAlive = $false + try { + Invoke-WithRetry -MaxAttempts 3 -DelaySeconds 0 -Action { + $script:orphanAttempts++ + Invoke-UnityCliCaptureWithTimeout ` + -Arguments @('-NoLogo', '-NoProfile', '-EncodedCommand', (ConvertTo-EncodedCommand $orphanParent)) ` + -TimeoutSeconds 10 ` + -StallSeconds 1 + } + } catch { + $orphanSafetyErrorEscaped = ($_.Exception.Message -match 'safe process-tree termination could not be confirmed') + $orphanSafetyMarker = [bool]$_.Exception.Data['DxMessagingNonRetryable'] + } finally { + if (Test-Path -LiteralPath $orphanPidPath -PathType Leaf) { + $orphanId = [int][IO.File]::ReadAllText($orphanPidPath) + $orphanWasAlive = $null -ne (Get-Process -Id $orphanId -ErrorAction SilentlyContinue) + Stop-Process -Id $orphanId -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $orphanPidPath -Force -ErrorAction SilentlyContinue + } + } + Assert-That 'quick-exit parent leaves a live descendant holding inherited pipes' ($orphanId -gt 0 -and $orphanWasAlive) + Assert-That 'actual orphan path produces the process-safety error' $orphanSafetyErrorEscaped + Assert-That 'actual orphan path marks the error as non-retryable' $orphanSafetyMarker + Assert-That 'actual orphan path is attempted exactly once' ($orphanAttempts -eq 1) +} finally { + if ($null -eq $savedNoticeInterval) { + Remove-Item Env:DXM_ENSURE_EDITOR_PROGRESS_NOTICE_INTERVAL_SECONDS -ErrorAction SilentlyContinue + } else { + $env:DXM_ENSURE_EDITOR_PROGRESS_NOTICE_INTERVAL_SECONDS = $savedNoticeInterval + } + if ($null -eq $savedStallSeconds) { + Remove-Item Env:DXM_ENSURE_EDITOR_PROGRESS_STALL_SECONDS -ErrorAction SilentlyContinue + } else { + $env:DXM_ENSURE_EDITOR_PROGRESS_STALL_SECONDS = $savedStallSeconds + } +} + +Write-Host "Unity editor heartbeat tests: $passed passed, $failed failed." +if ($failed -gt 0) { + exit 1 +} diff --git a/scripts/__tests__/test-unity-editor-heartbeat.ps1.meta b/scripts/__tests__/test-unity-editor-heartbeat.ps1.meta new file mode 100644 index 00000000..7df728ff --- /dev/null +++ b/scripts/__tests__/test-unity-editor-heartbeat.ps1.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a853c18d4e9d48cf80c6fe04e1bd7841 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/unity/ensure-editor.ps1 b/scripts/unity/ensure-editor.ps1 index a72bd67c..e60c3ab4 100644 --- a/scripts/unity/ensure-editor.ps1 +++ b/scripts/unity/ensure-editor.ps1 @@ -268,8 +268,19 @@ function Invoke-WithRetry { try { return & $Action } catch { - $lastError = $_ - $message = $_.Exception.Message + $caughtError = $_ + $nonRetryable = $false + try { + $nonRetryable = [bool]$caughtError.Exception.Data['DxMessagingNonRetryable'] + } catch { + $nonRetryable = $false + } + if ($nonRetryable) { + Write-Host "::error::Attempt $attempt of $MaxAttempts failed with a non-retryable process-safety error: $($caughtError.Exception.Message)" + throw $caughtError + } + $lastError = $caughtError + $message = $caughtError.Exception.Message if ($attempt -lt $MaxAttempts) { $sleep = $DelaySeconds * $attempt Write-Host "::warning::Attempt $attempt of $MaxAttempts failed: $message. Retrying in $sleep second(s)." @@ -353,40 +364,33 @@ 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. + # fallback): the heartbeat detector fires when the CLI emits NO stdout/stderr + # activity for >= this many seconds. Unity's beta CLI repeats one byte-identical + # 50-percent install triple during a healthy, long on-disk unpack, so an + # unchanged (pct, phase, msg) is not evidence of a hang. A silent child is + # still killed and classified as retryable (sentinel exit 125); a noisy child + # that never completes remains bounded by the wall-clock sentinel 124. # # Honors DXM_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 + # (e.g. 1) 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 + # Default rationale (PROFILE-AWARE): 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). + # triple throughout its on-disk work. Runs 26701943540 and 30534211162 show + # that an unchanged triple cannot establish a hang: the latter still emitted + # a line every quarter-second at the 1800-second threshold. They do not prove + # that install would have completed or identify the later directory locker. + # The detector therefore measures actual output silence. The profile-aware + # 900/1800-second defaults remain conservative bounds for a child that stops + # communicating, while the independent 2700-second wall clock bounds a child + # that stays noisy forever. + # The env override remains authoritative and is honored verbatim (tests set it + # to 2 to force the silent-stall path; 0 opts out). # StrictMode-safe: no collection reads. param([int]$Default = -1) @@ -601,11 +605,10 @@ 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). + # NOT a JSON progress line. Used by Invoke-UnityCliCaptureWithTimeout to keep + # the periodic diagnostic notice on the latest reported phase. Heartbeat + # liveness is based on any stdout/stderr activity, not triple changes, because + # the beta CLI repeats an unchanged triple during healthy on-disk unpacking. # # Deliberately regex-based (no ConvertFrom-Json): the lines are interleaved # progress spam, not a single JSON document, and a malformed/non-JSON beta @@ -872,12 +875,24 @@ function Ensure-UnityCli { 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 Test-IsNonRetryableProcessSafetyError { + param($ErrorRecord) + + try { + return [bool]$ErrorRecord.Exception.Data['DxMessagingNonRetryable'] + } catch { + return $false + } +} + function Invoke-UnityCliSafe { - # NON-THROWING best-effort invoker. The standalone Unity CLI is a moving + # Best-effort invoker for ordinary CLI failures. 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. + # variant returns $true/$false and never throws on a native non-zero exit code. + # An unconfirmed process-tree termination remains a marked, terminating safety + # error so no caller can continue provisioning around a possibly live child. # 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. @@ -891,9 +906,10 @@ function Invoke-UnityCliSafe { } function Get-UnityCliOutput { - # CAPTURING, NON-THROWING invoker for getter-style commands (install-path, + # CAPTURING 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 + # success, or $null on an ordinary/native failure. An unconfirmed tree + # termination throws the marked safety error. 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) @@ -923,6 +939,9 @@ function Get-UnityCliVersionText { $script:UnityCliVersionText = '(unavailable)' } } catch { + if (Test-IsNonRetryableProcessSafetyError -ErrorRecord $_) { + throw + } $script:UnityCliVersionText = "(query failed: $($_.Exception.Message))" } @@ -930,7 +949,7 @@ function Get-UnityCliVersionText { } function Invoke-UnityCliCapture { - # CAPTURING, NON-THROWING invoker that returns BOTH the exit status AND the + # CAPTURING 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 @@ -945,12 +964,15 @@ function Invoke-UnityCliCapture { # 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); + # (no stdout/stderr activity for the stall window); # see Invoke-UnityCliCaptureWithTimeout # TimedOutWallClock [bool] - $true when killed by the absolute wall-clock timeout; # mutually exclusive with StallKilled + # DirectChildExited [bool] - $true when direct CLI child exit was confirmed # Every field is always populated, so callers can read .Output.Count and - # index .Output without the 0/1/many AutomationNull hazard. + # index .Output without the 0/1/many AutomationNull hazard. The one deliberate + # exception is an unconfirmed process-tree termination: that throws a marked, + # non-retryable safety error instead of returning a misleading retryable result. param([Parameter(Mandatory = $true)][string[]]$Arguments) # DELEGATE to the timeout-capable runner so EVERY captured CLI invocation @@ -959,9 +981,9 @@ function Invoke-UnityCliCapture { # 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 + # semantics, return shape `@{ Success; ExitCode; Output; StallKilled; + # TimedOutWallClock; DirectChildExited }`, 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 @@ -979,8 +1001,45 @@ function Invoke-UnityCliCapture { return Invoke-UnityCliCaptureWithTimeout -Arguments $Arguments -TimeoutSeconds $effectiveTimeout } +function Confirm-UnityCliDirectChildExit { + param( + [Parameter(Mandatory = $true)]$Process, + [bool]$TerminationUnconfirmed + ) + + $reaped = $Process.WaitForExit(5000) + try { + $directChildExited = [bool]($reaped -and $Process.HasExited) + } catch { + $directChildExited = $false + } + if (-not $directChildExited) { + try { + $Process.Kill($true) + } catch { + $TerminationUnconfirmed = $true + try { $Process.Kill() } catch { } + } + $reaped = $Process.WaitForExit(5000) + try { + $directChildExited = [bool]($reaped -and $Process.HasExited) + } catch { + $directChildExited = $false + } + } + if (-not $directChildExited) { + $TerminationUnconfirmed = $true + } + + return @{ + Reaped = [bool]$reaped + DirectChildExited = [bool]$directChildExited + TerminationUnconfirmed = [bool]$TerminationUnconfirmed + } +} + function Invoke-UnityCliCaptureWithTimeout { - # TIMEOUT-CAPABLE, CAPTURING, NON-THROWING invoker -- the resilience core. It + # TIMEOUT-CAPABLE, CAPTURING 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 @@ -990,8 +1049,10 @@ function Invoke-UnityCliCaptureWithTimeout { # 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. + # whole tree, so a hang is bounded, termination is requested, and a confirmed + # request is classified as a retryable failure and annotated. If tree + # termination cannot be confirmed safely, the wrapper throws a marked, + # non-retryable process-safety error. # # 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 @@ -1018,16 +1079,13 @@ function Invoke-UnityCliCaptureWithTimeout { # 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. + # HEARTBEAT-STALL DETECTOR: a child that emits no stdout/stderr activity for + # >= $StallSeconds is classified as hung and tree-killed with sentinel exit + # 125 (distinct from the wall-clock 124). Repeated progress lines count as + # activity even when their (pct, phase, msg) triple is unchanged: Unity's beta + # CLI legitimately repeats one 50-percent triple throughout a long unpack. + # The independent wall-clock deadline still bounds a noisy child that never + # completes. # # The periodic ::notice:: emitted every PROGRESS_NOTICE_INTERVAL_SECONDS # makes the live CI log human-readable mid-flight (the alternative is a @@ -1036,7 +1094,7 @@ function Invoke-UnityCliCaptureWithTimeout { # 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 + # 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 @@ -1058,6 +1116,9 @@ function Invoke-UnityCliCaptureWithTimeout { # deadline killed the process (sentinel # exit 124 from THIS wrapper). $false on # a NATIVE 124 from the CLI. + # DirectChildExited [bool] - $true when direct CLI child exit was + # confirmed. A timeout that cannot confirm + # this throws a non-retryable safety error. param( [Parameter(Mandatory = $true)][string[]]$Arguments, [int]$TimeoutSeconds = 2700, @@ -1120,6 +1181,8 @@ function Invoke-UnityCliCaptureWithTimeout { $exit = -1 $timedOut = $false $reaped = $false + $directChildExited = $false + $terminationUnconfirmed = $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. @@ -1137,6 +1200,7 @@ function Invoke-UnityCliCaptureWithTimeout { $proc.StartInfo = $psi [void]$proc.Start() + $clock = [System.Diagnostics.Stopwatch]::StartNew() # 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 @@ -1149,26 +1213,16 @@ function Invoke-UnityCliCaptureWithTimeout { $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 + # Heartbeat-stall + periodic-notice state. Every stdout/stderr line resets + # the activity clock; the "last triple" is retained only for the readable + # notice. The notice clock is independent (time-gated, not output-gated) + # so a long install still gets a human-readable cadence in the live log + # instead of the raw dupe wall. Opt-out semantics: $StallSeconds == 0 + # disables heartbeat termination entirely. Stopwatch supplies monotonic + # elapsed time, so wall-clock adjustments cannot fire either guard early + # or postpone it. + $lastActivityMs = [int64]0 + $lastNoticeMs = [int64]0 $lastTriple = $null $stallEnabled = ($StallSeconds -gt 0) $stalled = $false @@ -1185,17 +1239,10 @@ function Invoke-UnityCliCaptureWithTimeout { } 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. + $lastActivityMs = $clock.ElapsedMilliseconds $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 - } + $lastTriple = $triple } $oTask = $outReader.ReadLineAsync() } @@ -1209,57 +1256,62 @@ function Invoke-UnityCliCaptureWithTimeout { } else { Write-Host $line $buffer.Add([string]$line) + $lastActivityMs = $clock.ElapsedMilliseconds $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 - } + $lastTriple = $triple } $eTask = $errReader.ReadLineAsync() } $progressed = $true } - $nowUtc = [DateTime]::UtcNow + $elapsedMs = $clock.ElapsedMilliseconds # 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 + # glance how far the install has come and how long the child has been + # silent. + $sinceNotice = ($elapsedMs - $lastNoticeMs) / 1000.0 if ($progressNoticeEnabled -and $sinceNotice -ge $progressNoticeIntervalSeconds) { - $lastNoticeAt = $nowUtc - $elapsedSec = [int][Math]::Floor(($nowUtc - $startedAt).TotalSeconds) - $stallElapsedSec = [int][Math]::Floor(($nowUtc - $lastTripleAdvanceAt).TotalSeconds) + $lastNoticeMs = $elapsedMs + $elapsedSec = [int][Math]::Floor($elapsedMs / 1000.0) + $idleElapsedSec = [int][Math]::Floor(($elapsedMs - $lastActivityMs) / 1000.0) 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" + Write-Host "::notice::Unity CLI install heartbeat: pct=$pctText phase=$phaseText msg=`"$msgText`" elapsed=${elapsedSec}s idleElapsed=${idleElapsedSec}s" } else { - Write-Host "::notice::Unity CLI install heartbeat: no progress line observed yet elapsed=${elapsedSec}s stallElapsed=${stallElapsedSec}s" + Write-Host "::notice::Unity CLI install heartbeat: no progress line observed yet elapsed=${elapsedSec}s idleElapsed=${idleElapsedSec}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) { + # out AND the child has emitted no stdout/stderr for >= the configured + # window. Tree-kills with the distinct stall sentinel so the failure + # diagnostic can distinguish output silence from wall-clock expiry. + if ($stallEnabled -and ($elapsedMs - $lastActivityMs) -ge ([int64]$StallSeconds * 1000)) { $stalled = $true $timedOut = $true + try { + # An already-exited parent cannot identify or terminate a + # reparented descendant that is still holding these pipes. + # Fail closed instead of retrying into a possible editor lock. + $terminationUnconfirmed = [bool]$proc.HasExited + } catch { } try { $proc.Kill($true) } catch { + # A direct-child fallback cannot reach descendants. Even if + # it succeeds, do not allow a provisioning retry. + $terminationUnconfirmed = $true try { $proc.Kill() } catch { } } break } - if ($nowUtc -ge $deadline) { + if ($hasDeadline -and $elapsedMs -ge $timeoutMs) { # 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 @@ -1268,12 +1320,16 @@ function Invoke-UnityCliCaptureWithTimeout { # 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 { + $terminationUnconfirmed = [bool]$proc.HasExited + } catch { } 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. + $terminationUnconfirmed = $true try { $proc.Kill() } catch { } } break @@ -1287,10 +1343,20 @@ function Invoke-UnityCliCaptureWithTimeout { } } - # 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) + # The loop ended either at EOF on both streams (normal/early exit) or + # after requesting termination. Reap the direct child so ExitCode and + # timeout classification are evidence-backed. + $exitConfirmation = Confirm-UnityCliDirectChildExit ` + -Process $proc ` + -TerminationUnconfirmed:$terminationUnconfirmed + $reaped = [bool]$exitConfirmation.Reaped + $directChildExited = [bool]$exitConfirmation.DirectChildExited + $terminationUnconfirmed = [bool]$exitConfirmation.TerminationUnconfirmed + if (-not $directChildExited) { + # Starting another provisioning attempt while this process may hold + # the editor tree is unsafe. + $timedOut = $true + } # 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. @@ -1307,10 +1373,13 @@ function Invoke-UnityCliCaptureWithTimeout { } 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) { + # Return a timeout sentinel only after the direct child is confirmed + # exited. An unconfirmed child is a non-retryable process-safety + # failure handled below. + if ($terminationUnconfirmed -or -not $directChildExited) { + $terminationUnconfirmed = $true + $exit = 126 + } elseif ($stalled) { $exit = $stallExitCode } else { $exit = $timeoutExitCode @@ -1346,6 +1415,13 @@ function Invoke-UnityCliCaptureWithTimeout { if (Get-Command Add-ProvisioningTimeoutEvent -ErrorAction SilentlyContinue) { Add-ProvisioningTimeoutEvent -Arguments $Arguments -TimeoutSeconds $TimeoutSeconds } + if ($terminationUnconfirmed) { + $message = "Unity CLI command '$($Arguments -join ' ')' exceeded a liveness guard, but safe process-tree termination could not be confirmed. The direct child either exited before the termination request, leaving any reparented descendant unreachable, or did not exit after two bounded termination requests. Provisioning will not retry while a process may still hold the editor tree." + Write-Host "::error::$message" + $exception = New-Object System.InvalidOperationException($message) + $exception.Data['DxMessagingNonRetryable'] = $true + throw $exception + } # 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, @@ -1358,13 +1434,13 @@ function Invoke-UnityCliCaptureWithTimeout { 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 + # killed for "no output activity in N seconds" (this branch) versus # "exceeded the total wall-clock budget" (the else branch below). $stallKnobName = if ($StallKnob) { $StallKnob } else { 'DXM_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" + Write-Host "::error::Unity CLI command '$($Arguments -join ' ')' HEARTBEAT STALLED after $StallSeconds second(s) with no stdout/stderr activity; tree termination was requested and direct child exit was confirmed (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 { 'DXM_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" + Write-Host "::error::Unity CLI command '$($Arguments -join ' ')' TIMED OUT after $TimeoutSeconds second(s); tree termination was requested and direct child exit was confirmed (sentinel exit $timeoutExitCode). Raise the limit via $knob (0 disables the timeout). Last progress message: $lastProgress. Collapsed tail:`n$collapsedTail" } } @@ -1382,6 +1458,7 @@ function Invoke-UnityCliCaptureWithTimeout { Output = @($captured) StallKilled = [bool]$stalled TimedOutWallClock = [bool]($timedOut -and -not $stalled) + DirectChildExited = [bool]$directChildExited } } @@ -2806,6 +2883,9 @@ function Move-UnityVersionInstallToQuarantine { $candidateRoots.Add($cliRoot) } } catch { + if (Test-IsNonRetryableProcessSafetyError -ErrorRecord $_) { + throw + } Write-Host "::notice::Could not query Unity CLI install root while quarantining Unity ${Version}: $($_.Exception.Message)" } @@ -3984,7 +4064,7 @@ function Test-TextIndicatesEditorNotModuleManageable { } function Test-UnityEditorModuleManageable { - # Best-effort, NON-THROWING probe: is the editor at $Version in a state where the + # Best-effort 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[]] }. # @@ -4010,6 +4090,9 @@ function Test-UnityEditorModuleManageable { $effectiveTimeout = Get-EffectiveUnityCliTimeoutSeconds -RequestedSeconds $requestedTimeout $result = Invoke-UnityCliCaptureWithTimeout -Arguments @('install-modules', '-e', $Version, '-l') -TimeoutSeconds $effectiveTimeout -TimeoutKnob 'DXM_ENSURE_EDITOR_PROBE_TIMEOUT_SECONDS' } catch { + if (Test-IsNonRetryableProcessSafetyError -ErrorRecord $_) { + throw + } return @{ Manageable = $true; Reason = "module-manageability probe threw: $($_.Exception.Message)"; Output = @() } } @@ -4174,8 +4257,9 @@ function Ensure-UnityCiModules { # 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. + # Attempt the install via the capturing path so we can inspect BOTH the exit + # code AND output before deciding whether an ordinary failure was fatal. + # An unconfirmed tree termination throws immediately and cannot be retried. $result = Invoke-UnityCliCapture -Arguments $installArgs # Re-verify the core tier on disk (disk is the source of truth; an exit 6 @@ -4263,6 +4347,9 @@ function Write-InstalledEditorDiagnostics { Write-Host "Unity CLI reported install root: (unavailable)" } } catch { + if (Test-IsNonRetryableProcessSafetyError -ErrorRecord $_) { + throw + } Write-Host "::notice::Could not query Unity CLI install root: $($_.Exception.Message)" } @@ -4280,6 +4367,9 @@ function Write-InstalledEditorDiagnostics { Write-Host "Installed Unity editors reported by CLI:" Invoke-UnityCliSafe -Arguments @('editors', '-i') | Out-Null } catch { + if (Test-IsNonRetryableProcessSafetyError -ErrorRecord $_) { + throw + } Write-Host "::notice::Could not query installed Unity editors: $($_.Exception.Message)" } Write-Host "::endgroup::" @@ -4307,6 +4397,9 @@ function Write-InstallDiagnostics { try { Write-Host "Unity CLI version: $(Get-UnityCliVersionText)" } catch { + if (Test-IsNonRetryableProcessSafetyError -ErrorRecord $_) { + throw + } Write-Host "::notice::Could not query the Unity CLI version: $($_.Exception.Message)" } @@ -4476,8 +4569,9 @@ if (-not $editor) { # 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 + # the timeout runner), so a hang with a confirmed tree-termination request is + # classified as a retryable failure that Invoke-WithRetry can re-attempt. + # Unconfirmed termination fails closed without retry. 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 + From 87a1e6056f9d8742bcfcfab1f1f52efa26e3514f Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 30 Jul 2026 17:55:26 +0000 Subject: [PATCH 13/22] test(ci): consolidate workflow contract checks --- .../session-177-safe-editor-capture-proof.md | 3 +- .../__tests__/ci-aggregate-workflow.test.js | 63 +++++++++---------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/progress/session-177-safe-editor-capture-proof.md b/progress/session-177-safe-editor-capture-proof.md index cb625872..22059ba8 100644 --- a/progress/session-177-safe-editor-capture-proof.md +++ b/progress/session-177-safe-editor-capture-proof.md @@ -203,7 +203,8 @@ That integration raised JavaScript source LOC to 17,565 against the 17,500 budget. A table-driven consolidation in `scripts/__tests__/ci-aggregate-workflow.test.js` preserves the workflow contracts while removing repeated assertion scaffolding and reduces the total -to 17,498. The focused aggregate-workflow suite passes all 18 tests, and the +to 17,485 after synchronizing the three newer PR-head commits. The focused +aggregate-workflow suite passes all 17 data-driven tests, and the heartbeat probe passes all 32 assertions. The final full Node and repository validation reruns remain pending after these changes. The full merged `WallstopStudios.DxMessaging.Tests.Editor` assembly then passed 549 tests with diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index 404e2632..6fbb5fe6 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -94,8 +94,8 @@ const STATIC_CHILD_JOBS = [ ["lint-doc-links", "docs_links"] ]; -function readCiWorkflow() { - return fs.readFileSync(path.join(WORKFLOW_DIR, "ci.yml"), "utf8"); +function readWorkflow(file = "ci.yml") { + return fs.readFileSync(path.join(WORKFLOW_DIR, file), "utf8"); } function escapeRegExp(value) { @@ -156,7 +156,7 @@ function extractShellPatternVariable(source, variableName) { } test("static CI checks stay consolidated behind CI Success", () => { - const source = readCiWorkflow(); + const source = readWorkflow(); const ciSuccess = getJobBlock(source, "ci-success"); assert.match(ciSuccess, /\n name: CI Success\n/); assert.match(ciSuccess, /\n if: \$\{\{ always\(\) \}\}\n/); @@ -170,7 +170,7 @@ test("static CI checks stay consolidated behind CI Success", () => { }); test("change detector considers current and previous paths", () => { - const source = readCiWorkflow(); + const source = readWorkflow(); assert.match(source, /--jq '\.\[\] \| \.filename, \(\.previous_filename \/\/ empty\)'/); assert.match( source, @@ -187,7 +187,7 @@ test("change detector considers current and previous paths", () => { }); test("script-test path detector covers harness and package contract inputs", () => { - const source = readCiWorkflow(); + const source = readWorkflow(); const scriptsPattern = new RegExp(extractShellPatternVariable(source, "scripts_pattern")); for (const path of [ @@ -203,7 +203,7 @@ test("script-test path detector covers harness and package contract inputs", () }); test("static child jobs always report and fail closed on bad change detection", () => { - const source = readCiWorkflow(); + const source = readWorkflow(); for (const [jobId, output] of STATIC_CHILD_JOBS) { const jobBlock = getJobBlock(source, jobId); @@ -235,7 +235,7 @@ test("static child jobs always report and fail closed on bad change detection", }); test("committed line endings are gated, not silently repaired", () => { - const source = readCiWorkflow(); + const source = readWorkflow(); const job = getJobBlock(source, "line-endings"); // Every tracked text file is in scope, so this job is intentionally unfiltered. @@ -297,7 +297,7 @@ test("the stuck-job watchdog never materializes the default branch", () => { }); test("source marker scan is tracked-file scoped and cannot self-match workflow text", () => { - const source = readCiWorkflow(); + const source = readWorkflow(); const dotnet = getJobBlock(source, "dotnet"); const markerScan = getStepBlock(dotnet, "Check source marker policy"); const includedPathspecs = [ @@ -326,7 +326,7 @@ test("source marker scan is tracked-file scoped and cannot self-match workflow t }); test("script validators run once while script tests stay cross-platform", () => { - const source = readCiWorkflow(); + const source = readWorkflow(); const scriptTests = getJobBlock(source, "script-tests"); const setupDotnet = getStepBlock(scriptTests, "Setup .NET"); const validators = getStepBlock(scriptTests, "Run validators"); @@ -385,22 +385,29 @@ test("every Unity lock window releases with explicit cleanup proof", () => { const classify = `uses: ${LOCK_ACTION_PREFIX}classify-unity-cleanup-evidence@${CLEANUP_POLICY_SHA}`; const release = `uses: ${LOCK_ACTION_PREFIX}release-build-lock@${CLEANUP_POLICY_SHA}`; const gate = `uses: ${LOCK_ACTION_PREFIX}require-confirmed-unity-cleanup@${CLEANUP_POLICY_SHA}`; - // prettier-ignore - const runnerLabels = new Map([["perf-numbers.yml", '[["self-hosted","Windows","RAM-64GB","fast"]]'], ["release.yml", '[["self-hosted","Windows","RAM-64GB"]]'], ["runner-bootstrap.yml", '[["self-hosted","Windows","RAM-64GB"]]'], ["unity-benchmarks.yml", '[["self-hosted","Windows","RAM-64GB"]]'], ["unity-tests.yml", '[["self-hosted","Windows","RAM-64GB"]]']]); - const preflightAction = - `${LOCK_ACTION_PREFIX}check-unity-runner-availability@${LOCK_ACTION_SHA}` + - LOCK_ACTION_PIN.comment; - const readWorkflow = (file) => fs.readFileSync(path.join(WORKFLOW_DIR, file), "utf8"); + const runnerLabels = new Map([ + ["perf-numbers.yml", '[["self-hosted","Windows","RAM-64GB","fast"]]'], + ["release.yml", '[["self-hosted","Windows","RAM-64GB"]]'], + ["runner-bootstrap.yml", '[["self-hosted","Windows","RAM-64GB"]]'], + ["unity-benchmarks.yml", '[["self-hosted","Windows","RAM-64GB"]]'], + ["unity-tests.yml", '[["self-hosted","Windows","RAM-64GB"]]'] + ]); + const preflightAction = `${LOCK_ACTION_PREFIX}check-unity-runner-availability@${LOCK_ACTION_SHA}${LOCK_ACTION_PIN.comment}`; const workflowSources = fs .readdirSync(WORKFLOW_DIR) .filter((file) => /\.ya?ml$/.test(file)) .map(readWorkflow); for (const [file, labels] of runnerLabels) { - const source = readWorkflow(file); - const preflight = getJobBlock(source, "runner-preflight", file); - // prettier-ignore - const contracts = [/\n name: Self-hosted runner registration preflight\n/, /\n runs-on: ubuntu-latest\n/, new RegExp(`uses: ${escapeRegExp(preflightAction)}`), /reader-app-id: \$\{\{ secrets\.BUILD_LOCK_READER_APP_ID \}\}/, /reader-app-private-key: \$\{\{ secrets\.BUILD_LOCK_READER_APP_PRIVATE_KEY \}\}/, new RegExp(`required-label-sets: '${escapeRegExp(labels)}'`)]; + const preflight = getJobBlock(readWorkflow(file), "runner-preflight", file); + const contracts = [ + /\n name: Self-hosted runner registration preflight\n/, + /\n runs-on: ubuntu-latest\n/, + new RegExp(`uses: ${escapeRegExp(preflightAction)}`), + /reader-app-id: \$\{\{ secrets\.BUILD_LOCK_READER_APP_ID \}\}/, + /reader-app-private-key: \$\{\{ secrets\.BUILD_LOCK_READER_APP_PRIVATE_KEY \}\}/, + new RegExp(`required-label-sets: '${escapeRegExp(labels)}'`) + ]; for (const contract of contracts) assert.match(preflight, contract, file); assert.doesNotMatch(preflight, /RUNNER_AUDIT_PAT|Soft pass|soft-pass|Require an online/i, file); if (file === "runner-bootstrap.yml") @@ -415,8 +422,7 @@ test("every Unity lock window releases with explicit cleanup proof", () => { for (const [file, jobId, licensedWorkName, emptyAware] of UNITY_LOCK_WINDOWS) { const label = `${file}:${jobId}`; const licensedCondition = `${emptyAware ? "steps\\.compute\\.outputs\\.is-empty != 'true' && " : ""}steps\\.acquire_lock\\.outputs\\.acquired == 'true'`; - const source = readWorkflow(file); - const job = getJobBlock(source, jobId, file); + const job = getJobBlock(readWorkflow(file), jobId, file); if (["perf-numbers.yml", "unity-benchmarks.yml", "unity-tests.yml"].includes(file)) { assert.match(job, /\n fail-fast: false\n max-parallel: 1\n/, `${label} fairness`); } @@ -434,14 +440,8 @@ test("every Unity lock window releases with explicit cleanup proof", () => { ); assert.deepEqual(positions, sortedPositions, `${label} lifecycle order`); - const acquireStep = getStepBlock(job, "Acquire organization Unity lock"); - const provisionStep = getStepBlock(job, "Provision Unity Editor"); - const requireStep = getStepBlock(job, "Require acquired Unity lock"); - const workStep = getStepBlock(job, licensedWorkName); - const returnStep = getStepBlock(job, "Return Unity license"); - const classifyStep = getStepBlock(job, "Classify Unity cleanup evidence"); - const releaseStep = getStepBlock(job, "Release organization Unity lock"); - const gateStep = getStepBlock(job, "Require confirmed Unity cleanup"); + // prettier-ignore + const [acquireStep, requireStep, provisionStep, workStep, returnStep, classifyStep, releaseStep, gateStep] = lifecycleNames.map((name) => getStepBlock(job, name)); // prettier-ignore const contracts = [ @@ -457,10 +457,9 @@ test("every Unity lock window releases with explicit cleanup proof", () => { [releaseStep, /resource-cleanup-status: \$\{\{ steps\.cleanup_classification\.outputs\.resource-cleanup-status \}\}/], [gateStep, /\n if: always\(\)\n/], [gateStep, /classification-complete: \$\{\{ steps\.cleanup_classification\.outputs\.classification-complete \}\}/], - [gateStep, /release-outcome: \$\{\{ steps\.release_unity_lock\.outcome \}\}/] + [gateStep, /release-outcome: \$\{\{ steps\.release_unity_lock\.outcome \}\}/], + ...(file === "unity-tests.yml" ? [[workStep, /-UnityInstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, `${label}: licensed work and central return must use the same trusted editor root`]] : []) ]; - // prettier-ignore - if (file === "unity-tests.yml") contracts.push([workStep, /-UnityInstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, `${label}: licensed work and central return must use the same trusted editor root`]); for (const [actual, contract, message] of contracts) assert.match(actual, contract, message); assert.doesNotMatch( From 148de4e8a09b72d3ec88b1ea94d17e6237f0fd9a Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 30 Jul 2026 17:57:02 +0000 Subject: [PATCH 14/22] docs(progress): record final provisioning validation --- progress/session-177-safe-editor-capture-proof.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/progress/session-177-safe-editor-capture-proof.md b/progress/session-177-safe-editor-capture-proof.md index 22059ba8..16d8bc39 100644 --- a/progress/session-177-safe-editor-capture-proof.md +++ b/progress/session-177-safe-editor-capture-proof.md @@ -205,8 +205,10 @@ budget. A table-driven consolidation in contracts while removing repeated assertion scaffolding and reduces the total to 17,485 after synchronizing the three newer PR-head commits. The focused aggregate-workflow suite passes all 17 data-driven tests, and the -heartbeat probe passes all 32 assertions. The final full Node and repository -validation reruns remain pending after these changes. The full merged +heartbeat probe passes all 32 assertions. The final full Node suite passes 406 +tests with zero failures. `npm run validate:all`, strict documentation build, +Actionlint, Yamllint, PowerShell parsing, and `git diff --check` also pass. The +full merged `WallstopStudios.DxMessaging.Tests.Editor` assembly then passed 549 tests with zero failures in 12.3873838 seconds through `DxMcpTestRunner`; the result is retained at From 5cdcd2d307e7f9bd696ea89f0e2cde0de34eae9f Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 30 Jul 2026 18:02:42 +0000 Subject: [PATCH 15/22] test(ci): classify second-reap Unity termination --- .../session-177-safe-editor-capture-proof.md | 12 +++++----- .../__tests__/test-unity-editor-heartbeat.ps1 | 23 +++++++++++++++++-- scripts/unity/ensure-editor.ps1 | 6 +++++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/progress/session-177-safe-editor-capture-proof.md b/progress/session-177-safe-editor-capture-proof.md index 16d8bc39..36ca7bc4 100644 --- a/progress/session-177-safe-editor-capture-proof.md +++ b/progress/session-177-safe-editor-capture-proof.md @@ -190,12 +190,14 @@ six lines. After the fix, all eight lines completed with exit 0 and killed after one second with exit 125 and `StallKilled == true`. The experiment is now retained in -`scripts/__tests__/test-unity-editor-heartbeat.ps1`. Its 32 assertions exercise +`scripts/__tests__/test-unity-editor-heartbeat.ps1`. Its 37 assertions exercise stdout and stderr activity, the environment override, monotonic periodic notices, direct-child exit confirmation, descendant tree termination, the noisy wall deadline, both outcomes of the second bounded reap, and the actual fail-closed producer/consumer path for a quick-exit parent whose live orphan -holds inherited pipes. CI executes the test on Linux, macOS, and Windows. +holds inherited pipes. A native pipe-closing child also proves that a successful +second-reap termination is attributed to wrapper timeout rather than a native +exit. CI executes the test on Linux, macOS, and Windows. The branch was then merged with current `master` at `645cde05`, bringing in the session 176 prototype-exclusion guard and the latest performance-number update. @@ -205,7 +207,7 @@ budget. A table-driven consolidation in contracts while removing repeated assertion scaffolding and reduces the total to 17,485 after synchronizing the three newer PR-head commits. The focused aggregate-workflow suite passes all 17 data-driven tests, and the -heartbeat probe passes all 32 assertions. The final full Node suite passes 406 +heartbeat probe passes all 37 assertions. The final full Node suite passes 406 tests with zero failures. `npm run validate:all`, strict documentation build, Actionlint, Yamllint, PowerShell parsing, and `git diff --check` also pass. The full merged @@ -216,9 +218,7 @@ retained at ## Remaining work -- Commit the current provisioning, regression-test, documentation, and - integration compaction changes; validate, push, and obtain current reviews and - required checks. +- Push the committed branch and obtain current reviews and required checks. - Switch the host to Personal/light through the Unity UI. - Resolve the manifest's Unity 2022.3 LTS requirement versus the configured 6000.4.6f1 host. diff --git a/scripts/__tests__/test-unity-editor-heartbeat.ps1 b/scripts/__tests__/test-unity-editor-heartbeat.ps1 index d767e9c1..8458796f 100644 --- a/scripts/__tests__/test-unity-editor-heartbeat.ps1 +++ b/scripts/__tests__/test-unity-editor-heartbeat.ps1 @@ -168,6 +168,21 @@ while ($true) { Assert-That 'endless noisy child exit is confirmed' $result.DirectChildExited Assert-That 'endless noisy child emitted activity before its deadline' (@($result.Output).Count -gt 0) + $closedPipes = @' +Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class NativePipeClose { [DllImport("kernel32.dll")] private static extern IntPtr GetStdHandle(int id); [DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr handle); [DllImport("libc")] private static extern int close(int fd); public static void Close() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { CloseHandle(GetStdHandle(-11)); CloseHandle(GetStdHandle(-12)); } else { close(1); close(2); } } }' +[NativePipeClose]::Close() +Start-Sleep -Seconds 20 +'@ + $result = Invoke-UnityCliCaptureWithTimeout ` + -Arguments @('-NoLogo', '-NoProfile', '-EncodedCommand', (ConvertTo-EncodedCommand $closedPipes)) ` + -TimeoutSeconds 5 ` + -StallSeconds 0 + Assert-That 'closed-pipe child fails after the second reap termination' (-not $result.Success) + Assert-That 'closed-pipe child receives wrapper sentinel 124' ($result.ExitCode -eq 124) + Assert-That 'closed-pipe child is attributed to wrapper wall timeout' $result.TimedOutWallClock + Assert-That 'closed-pipe child is not attributed to heartbeat' (-not $result.StallKilled) + Assert-That 'closed-pipe child exit is confirmed' $result.DirectChildExited + $descendantCode = ConvertTo-EncodedCommand 'Start-Sleep -Seconds 10' $pwshPath = $script:UnityCliPath.Replace("'", "''") $descendantParent = @" @@ -195,7 +210,9 @@ Start-Sleep -Seconds 10 $secondAttemptSuccess = New-FakeTerminationProcess -ExitOnSecondWait $true $confirmation = Confirm-UnityCliDirectChildExit -Process $secondAttemptSuccess Assert-That 'second reap path performs two waits and one tree request' ( - $secondAttemptSuccess.WaitCalls -eq 2 -and $secondAttemptSuccess.KillCalls -eq 1 + $secondAttemptSuccess.WaitCalls -eq 2 -and + $secondAttemptSuccess.KillCalls -eq 1 -and + $confirmation.TerminationRequested ) Assert-That 'second reap path can confirm the direct child exit' $confirmation.DirectChildExited Assert-That 'successful second reap remains retry-safe' (-not $confirmation.TerminationUnconfirmed) @@ -203,7 +220,9 @@ Start-Sleep -Seconds 10 $secondAttemptFailure = New-FakeTerminationProcess -ExitOnSecondWait $false $confirmation = Confirm-UnityCliDirectChildExit -Process $secondAttemptFailure Assert-That 'exhausted second reap performs two waits and one tree request' ( - $secondAttemptFailure.WaitCalls -eq 2 -and $secondAttemptFailure.KillCalls -eq 1 + $secondAttemptFailure.WaitCalls -eq 2 -and + $secondAttemptFailure.KillCalls -eq 1 -and + $confirmation.TerminationRequested ) Assert-That 'exhausted second reap leaves direct child unconfirmed' (-not $confirmation.DirectChildExited) Assert-That 'exhausted second reap fails closed' $confirmation.TerminationUnconfirmed diff --git a/scripts/unity/ensure-editor.ps1 b/scripts/unity/ensure-editor.ps1 index e60c3ab4..20c3c2bb 100644 --- a/scripts/unity/ensure-editor.ps1 +++ b/scripts/unity/ensure-editor.ps1 @@ -1013,7 +1013,9 @@ function Confirm-UnityCliDirectChildExit { } catch { $directChildExited = $false } + $terminationRequested = $false if (-not $directChildExited) { + $terminationRequested = $true try { $Process.Kill($true) } catch { @@ -1035,6 +1037,7 @@ function Confirm-UnityCliDirectChildExit { Reaped = [bool]$reaped DirectChildExited = [bool]$directChildExited TerminationUnconfirmed = [bool]$TerminationUnconfirmed + TerminationRequested = [bool]$terminationRequested } } @@ -1352,6 +1355,9 @@ function Invoke-UnityCliCaptureWithTimeout { $reaped = [bool]$exitConfirmation.Reaped $directChildExited = [bool]$exitConfirmation.DirectChildExited $terminationUnconfirmed = [bool]$exitConfirmation.TerminationUnconfirmed + if ($exitConfirmation.TerminationRequested) { + $timedOut = $true + } if (-not $directChildExited) { # Starting another provisioning attempt while this process may hold # the editor tree is unsafe. From b0d0190089c6c71fbfb2a870596e1abd13f8348a Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 30 Jul 2026 18:05:48 +0000 Subject: [PATCH 16/22] test(ci): allow native pipe close spelling --- scripts/__tests__/test-unity-editor-heartbeat.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/__tests__/test-unity-editor-heartbeat.ps1 b/scripts/__tests__/test-unity-editor-heartbeat.ps1 index 8458796f..80ae03c7 100644 --- a/scripts/__tests__/test-unity-editor-heartbeat.ps1 +++ b/scripts/__tests__/test-unity-editor-heartbeat.ps1 @@ -168,6 +168,7 @@ while ($true) { Assert-That 'endless noisy child exit is confirmed' $result.DirectChildExited Assert-That 'endless noisy child emitted activity before its deadline' (@($result.Output).Count -gt 0) + # cspell:ignore libc $closedPipes = @' Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class NativePipeClose { [DllImport("kernel32.dll")] private static extern IntPtr GetStdHandle(int id); [DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr handle); [DllImport("libc")] private static extern int close(int fd); public static void Close() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { CloseHandle(GetStdHandle(-11)); CloseHandle(GetStdHandle(-12)); } else { close(1); close(2); } } }' [NativePipeClose]::Close() From e1dfee7d158159883c44f39709173d52a251b67c Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 30 Jul 2026 18:14:45 +0000 Subject: [PATCH 17/22] test: account for Windows detached pipe semantics --- .../session-177-safe-editor-capture-proof.md | 8 ++++--- .../__tests__/test-unity-editor-heartbeat.ps1 | 23 ++++++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/progress/session-177-safe-editor-capture-proof.md b/progress/session-177-safe-editor-capture-proof.md index 36ca7bc4..42b415de 100644 --- a/progress/session-177-safe-editor-capture-proof.md +++ b/progress/session-177-safe-editor-capture-proof.md @@ -193,9 +193,11 @@ The experiment is now retained in `scripts/__tests__/test-unity-editor-heartbeat.ps1`. Its 37 assertions exercise stdout and stderr activity, the environment override, monotonic periodic notices, direct-child exit confirmation, descendant tree termination, the noisy -wall deadline, both outcomes of the second bounded reap, and the actual -fail-closed producer/consumer path for a quick-exit parent whose live orphan -holds inherited pipes. A native pipe-closing child also proves that a successful +wall deadline, and both outcomes of the second bounded reap. On Linux and macOS, +the quick-exit parent retains inherited pipes and proves the actual fail-closed, +non-retryable producer/consumer path. On Windows, the detached child closes +those pipes and proves successful direct-child completion instead. A native +pipe-closing child also proves on all three platforms that a successful second-reap termination is attributed to wrapper timeout rather than a native exit. CI executes the test on Linux, macOS, and Windows. diff --git a/scripts/__tests__/test-unity-editor-heartbeat.ps1 b/scripts/__tests__/test-unity-editor-heartbeat.ps1 index 80ae03c7..d3bf2de4 100644 --- a/scripts/__tests__/test-unity-editor-heartbeat.ps1 +++ b/scripts/__tests__/test-unity-editor-heartbeat.ps1 @@ -11,7 +11,7 @@ - repeated, byte-identical progress remains alive and completes; - a silent child is killed by the heartbeat sentinel; - a noisy child that never finishes is killed by the wall-clock sentinel; - - a quick-exit parent with a live orphan fails closed without retry. + - a quick-exit parent exercises the platform's detached-child pipe semantics. #> [CmdletBinding()] param( @@ -235,12 +235,13 @@ Start-Sleep -Seconds 10 [IO.File]::WriteAllText('$orphanPidLiteral', [string]`$child.Id) "@ $orphanAttempts = 0 + $orphanResult = $null $orphanSafetyErrorEscaped = $false $orphanSafetyMarker = $false $orphanId = 0 $orphanWasAlive = $false try { - Invoke-WithRetry -MaxAttempts 3 -DelaySeconds 0 -Action { + $orphanResult = Invoke-WithRetry -MaxAttempts 3 -DelaySeconds 0 -Action { $script:orphanAttempts++ Invoke-UnityCliCaptureWithTimeout ` -Arguments @('-NoLogo', '-NoProfile', '-EncodedCommand', (ConvertTo-EncodedCommand $orphanParent)) ` @@ -258,10 +259,20 @@ Start-Sleep -Seconds 10 Remove-Item -LiteralPath $orphanPidPath -Force -ErrorAction SilentlyContinue } } - Assert-That 'quick-exit parent leaves a live descendant holding inherited pipes' ($orphanId -gt 0 -and $orphanWasAlive) - Assert-That 'actual orphan path produces the process-safety error' $orphanSafetyErrorEscaped - Assert-That 'actual orphan path marks the error as non-retryable' $orphanSafetyMarker - Assert-That 'actual orphan path is attempted exactly once' ($orphanAttempts -eq 1) + Assert-That 'quick-exit parent leaves a live detached descendant' ($orphanId -gt 0 -and $orphanWasAlive) + if ($IsWindows) { + Assert-That 'Windows detached descendant does not retain the redirected pipes' ( + $null -ne $orphanResult -and $orphanResult.Success + ) + Assert-That 'Windows direct-child completion does not produce a process-safety marker' ( + -not $orphanSafetyErrorEscaped -and -not $orphanSafetyMarker + ) + Assert-That 'Windows detached-child path is attempted exactly once' ($orphanAttempts -eq 1) + } else { + Assert-That 'inherited-pipe orphan produces the process-safety error' $orphanSafetyErrorEscaped + Assert-That 'inherited-pipe orphan marks the error as non-retryable' $orphanSafetyMarker + Assert-That 'inherited-pipe orphan path is attempted exactly once' ($orphanAttempts -eq 1) + } } finally { if ($null -eq $savedNoticeInterval) { Remove-Item Env:DXM_ENSURE_EDITOR_PROGRESS_NOTICE_INTERVAL_SECONDS -ErrorAction SilentlyContinue From 564af4c5d9f86dbf5139f973de5f6d2ff69ad8b7 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 30 Jul 2026 18:41:44 +0000 Subject: [PATCH 18/22] test: classify editor heartbeat as static --- .github/workflows/ci.yml | 2 +- ...tor-heartbeat.ps1 => test-editor-provisioning-heartbeat.ps1} | 2 +- ...eat.ps1.meta => test-editor-provisioning-heartbeat.ps1.meta} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename scripts/__tests__/{test-unity-editor-heartbeat.ps1 => test-editor-provisioning-heartbeat.ps1} (99%) rename scripts/__tests__/{test-unity-editor-heartbeat.ps1.meta => test-editor-provisioning-heartbeat.ps1.meta} (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe7e91ac..90ae1279 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -775,7 +775,7 @@ jobs: - name: Test Unity editor heartbeat guards if: ${{ needs.changes.outputs.scripts != 'false' }} shell: pwsh - run: ./scripts/__tests__/test-unity-editor-heartbeat.ps1 -VerboseOutput + run: ./scripts/__tests__/test-editor-provisioning-heartbeat.ps1 -VerboseOutput - name: Run validators if: ${{ needs.changes.outputs.scripts != 'false' && matrix.os == 'ubuntu-latest' }} diff --git a/scripts/__tests__/test-unity-editor-heartbeat.ps1 b/scripts/__tests__/test-editor-provisioning-heartbeat.ps1 similarity index 99% rename from scripts/__tests__/test-unity-editor-heartbeat.ps1 rename to scripts/__tests__/test-editor-provisioning-heartbeat.ps1 index d3bf2de4..db6122f5 100644 --- a/scripts/__tests__/test-unity-editor-heartbeat.ps1 +++ b/scripts/__tests__/test-editor-provisioning-heartbeat.ps1 @@ -1,7 +1,7 @@ #!/usr/bin/env pwsh <# .SYNOPSIS - Behavioral tests for the Unity CLI heartbeat and wall-clock guards. + Behavioral tests for editor-provisioning heartbeat and wall-clock guards. .DESCRIPTION Extracts the real timeout runner from ensure-editor.ps1 without executing diff --git a/scripts/__tests__/test-unity-editor-heartbeat.ps1.meta b/scripts/__tests__/test-editor-provisioning-heartbeat.ps1.meta similarity index 100% rename from scripts/__tests__/test-unity-editor-heartbeat.ps1.meta rename to scripts/__tests__/test-editor-provisioning-heartbeat.ps1.meta From 591eb090357c6fa52450cfcabc078829ce5f0963 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 30 Jul 2026 18:53:04 +0000 Subject: [PATCH 19/22] fix: preserve Unity lifecycle cleanup margin --- .github/workflows/perf-numbers.yml | 8 +++++--- .github/workflows/release.yml | 11 +++++++---- .github/workflows/unity-benchmarks.yml | 6 ++++-- .github/workflows/unity-tests.yml | 5 +++-- scripts/__tests__/ci-aggregate-workflow.test.js | 6 ++++-- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/.github/workflows/perf-numbers.yml b/.github/workflows/perf-numbers.yml index 4177c9fc..56ecc890 100644 --- a/.github/workflows/perf-numbers.yml +++ b/.github/workflows/perf-numbers.yml @@ -126,7 +126,7 @@ jobs: # Pinned to ELI-MACHINE via the `fast` label (the only self-hosted Windows # runner carrying it). Keep in sync with the preflight REQUIRED_LABELS. runs-on: [self-hosted, Windows, RAM-64GB, fast] - timeout-minutes: 660 + timeout-minutes: 720 strategy: fail-fast: false max-parallel: 1 @@ -253,6 +253,7 @@ jobs: # editor. Both mutate the shared tool-cache root, so installation and # repair are serialized by the organization lock before licensed work. - name: Provision Unity Editor + id: provision_editor if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 180 shell: pwsh @@ -285,11 +286,11 @@ jobs: - name: Run Unity Test Runner id: run_tests - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} + if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' && steps.provision_editor.outcome == 'success' }} # 180 (not 120): the Standalone leg builds a COLD IL2CPP player, which is # far slower than a Mono build. The PlayMode leg runs IN-EDITOR (Mono) with # no player build and is faster, so 180 is generous for it too. Stays - # strictly below the job-level timeout (660) so the step clock + # strictly below the job-level timeout (720) so the step clock # fires first and releases the shared Unity seat. Must stay >= the # validator's RUN_BUDGET (120). timeout-minutes: 180 @@ -318,6 +319,7 @@ jobs: } ./scripts/unity/run-ci-tests.ps1 ` -UnityVersion '${{ matrix.unity-version }}' ` + -UnityInstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` -TestMode '${{ matrix.test-mode }}' ` -AssemblyNames $env:DXM_TEST_ASSEMBLIES ` -ArtifactsPath '.artifacts/unity/perf/${{ matrix.unity-version }}-${{ matrix.test-mode }}' ` diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d528e675..a935234a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -185,7 +185,7 @@ jobs: permissions: contents: read checks: write - timeout-minutes: 660 + timeout-minutes: 720 steps: - name: Enable Git long paths shell: pwsh @@ -255,6 +255,7 @@ jobs: # Serialize shared tool-cache installation and repair with licensed work. - name: Provision Unity Editor + id: provision_editor if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 180 shell: pwsh @@ -282,7 +283,7 @@ jobs: - name: Run Unity Test Runner id: run_tests - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} + if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' && steps.provision_editor.outcome == 'success' }} timeout-minutes: 120 shell: pwsh env: @@ -294,6 +295,7 @@ jobs: run: | ./scripts/unity/run-ci-tests.ps1 ` -UnityVersion '2022.3.45f1' ` + -UnityInstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` -TestMode 'editmode' ` -AssemblyNames $env:DXM_TEST_ASSEMBLIES ` -ArtifactsPath '.artifacts/unity/release-editmode' ` @@ -414,7 +416,7 @@ jobs: runs-on: [self-hosted, Windows, RAM-64GB] permissions: contents: read - timeout-minutes: 660 + timeout-minutes: 720 steps: - name: Enable Git long paths shell: pwsh @@ -469,6 +471,7 @@ jobs: # The exporter needs only EditorOnly, but its shared cache mutation still # requires organization serialization before licensed work begins. - name: Provision Unity Editor + id: provision_editor if: ${{ steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 180 shell: pwsh @@ -500,7 +503,7 @@ jobs: - name: Export the .unitypackage id: export_unitypackage - if: ${{ steps.acquire_lock.outputs.acquired == 'true' }} + if: ${{ steps.acquire_lock.outputs.acquired == 'true' && steps.provision_editor.outcome == 'success' }} timeout-minutes: 120 shell: pwsh env: diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml index f90d716b..7c3799f9 100644 --- a/.github/workflows/unity-benchmarks.yml +++ b/.github/workflows/unity-benchmarks.yml @@ -39,7 +39,7 @@ jobs: needs: - runner-preflight runs-on: [self-hosted, Windows, RAM-64GB] - timeout-minutes: 660 + timeout-minutes: 720 strategy: fail-fast: false max-parallel: 1 @@ -145,6 +145,7 @@ jobs: # Serialize shared tool-cache installation and repair with licensed work. - name: Provision Unity Editor + id: provision_editor if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 180 shell: pwsh @@ -172,7 +173,7 @@ jobs: - name: Run Unity Test Runner id: run_tests - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} + if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' && steps.provision_editor.outcome == 'success' }} timeout-minutes: 120 shell: pwsh env: @@ -189,6 +190,7 @@ jobs: # the Release flags add no comparison rows. ./scripts/unity/run-ci-tests.ps1 ` -UnityVersion '${{ matrix.unity-version }}' ` + -UnityInstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` -TestMode '${{ matrix.test-mode }}' ` -AssemblyNames $env:DXM_TEST_ASSEMBLIES ` -ArtifactsPath '.artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }}' ` diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index e417df2f..e3b603db 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -69,7 +69,7 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: [self-hosted, Windows, RAM-64GB] - timeout-minutes: 660 + timeout-minutes: 720 strategy: fail-fast: false max-parallel: 1 @@ -197,6 +197,7 @@ jobs: # Provision only while holding the same lock that serializes licensed # work, so concurrent repositories cannot repair or replace one editor. - name: Provision Unity Editor + id: provision_editor if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 180 shell: pwsh @@ -229,7 +230,7 @@ jobs: - name: Run Unity Test Runner id: run_tests - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} + if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' && steps.provision_editor.outcome == 'success' }} # 150 (was 120): the standalone leg builds a NON-development IL2CPP # player with the Release C++ configuration. Measured PR runs showed the # Debug C++ configuration reduces native compile time but makes the diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index 6fbb5fe6..68729072 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -421,8 +421,9 @@ test("every Unity lock window releases with explicit cleanup proof", () => { for (const [file, jobId, licensedWorkName, emptyAware] of UNITY_LOCK_WINDOWS) { const label = `${file}:${jobId}`; - const licensedCondition = `${emptyAware ? "steps\\.compute\\.outputs\\.is-empty != 'true' && " : ""}steps\\.acquire_lock\\.outputs\\.acquired == 'true'`; + const licensedCondition = `${emptyAware ? "steps\\.compute\\.outputs\\.is-empty != 'true' && " : ""}steps\\.acquire_lock\\.outputs\\.acquired == 'true' && steps\\.provision_editor\\.outcome == 'success'`; const job = getJobBlock(readWorkflow(file), jobId, file); + assert.match(job, /\n timeout-minutes: 720\n/, `${label}: lifecycle budget`); if (["perf-numbers.yml", "unity-benchmarks.yml", "unity-tests.yml"].includes(file)) { assert.match(job, /\n fail-fast: false\n max-parallel: 1\n/, `${label} fairness`); } @@ -446,6 +447,7 @@ test("every Unity lock window releases with explicit cleanup proof", () => { // prettier-ignore const contracts = [ [acquireStep, /\n id: acquire_lock\n/], + [provisionStep, /\n id: provision_editor\n/], [provisionStep, /-InstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, `${label}: central cleanup and provisioning must use the same trusted editor root`], [requireStep, /\n if: \$\{\{ steps\.acquire_lock\.outputs\.acquired != 'true' \}\}\n[\s\S]*\n run: exit 1\n/], [workStep, new RegExp(`\\n if: \\$\\{\\{ ${licensedCondition} \\}\\}\\n`), label], @@ -458,7 +460,7 @@ test("every Unity lock window releases with explicit cleanup proof", () => { [gateStep, /\n if: always\(\)\n/], [gateStep, /classification-complete: \$\{\{ steps\.cleanup_classification\.outputs\.classification-complete \}\}/], [gateStep, /release-outcome: \$\{\{ steps\.release_unity_lock\.outcome \}\}/], - ...(file === "unity-tests.yml" ? [[workStep, /-UnityInstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, `${label}: licensed work and central return must use the same trusted editor root`]] : []) + ...(["perf-numbers.yml", "unity-benchmarks.yml", "unity-tests.yml"].includes(file) || jobId === "unity-checks" ? [[workStep, /-UnityInstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, `${label}: licensed work and central return must use the same trusted editor root`]] : []) ]; for (const [actual, contract, message] of contracts) assert.match(actual, contract, message); From 378caa1c0d25407c1ab60beb53c3c1913a8bc58b Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 30 Jul 2026 18:58:43 +0000 Subject: [PATCH 20/22] ci: reserve Unity cleanup timeout budget --- .github/workflows/perf-numbers.yml | 21 ++- .github/workflows/release.yml | 31 +++- .github/workflows/unity-benchmarks.yml | 17 ++- .github/workflows/unity-tests.yml | 21 ++- .llm/index.json | 2 +- .llm/skills/unity-editor-ci/SKILL.md | 20 +-- .../references/unity-ci-matrix.md | 10 +- .../session-177-safe-editor-capture-proof.md | 21 ++- .../__tests__/ci-aggregate-workflow.test.js | 5 +- scripts/validate-unity-pr-policy.py | 138 ++++++++++++++++++ 10 files changed, 260 insertions(+), 26 deletions(-) diff --git a/.github/workflows/perf-numbers.yml b/.github/workflows/perf-numbers.yml index 4177c9fc..33b5fc82 100644 --- a/.github/workflows/perf-numbers.yml +++ b/.github/workflows/perf-numbers.yml @@ -126,7 +126,7 @@ jobs: # Pinned to ELI-MACHINE via the `fast` label (the only self-hosted Windows # runner carrying it). Keep in sync with the preflight REQUIRED_LABELS. runs-on: [self-hosted, Windows, RAM-64GB, fast] - timeout-minutes: 660 + timeout-minutes: 900 strategy: fail-fast: false max-parallel: 1 @@ -151,12 +151,14 @@ jobs: - playmode steps: - name: Enable Git long paths + timeout-minutes: 2 shell: pwsh env: GIT_CONFIG_GLOBAL: ${{ runner.temp }}/dxm-gitconfig run: git config --global core.longpaths true - name: Checkout + timeout-minutes: 30 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 env: GIT_CONFIG_GLOBAL: ${{ runner.temp }}/dxm-gitconfig @@ -165,6 +167,7 @@ jobs: persist-credentials: false - name: Print runner diagnostics + timeout-minutes: 2 # Composite action runs PowerShell internally; `shell: bash` on this # self-hosted Windows runner resolves to the WSL stub and fails: # never use plain `shell: bash` on self-hosted Windows runners. @@ -174,6 +177,7 @@ jobs: requested-labels: "self-hosted, Windows, RAM-64GB, fast" - name: Cache Unity Library and package caches + timeout-minutes: 30 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 env: PACKAGE_HASH: >- @@ -194,6 +198,7 @@ jobs: key: Library-perf-${{ runner.os }}-${{ runner.arch }}-${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ env.PACKAGE_HASH }} - name: Setup Node.js + timeout-minutes: 5 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22.18.0" @@ -203,6 +208,7 @@ jobs: # as unity-benchmarks.yml does. - name: Compute benchmark assembly list id: compute + timeout-minutes: 5 uses: ./.github/actions/compute-unity-assemblies with: include-perf: "true" @@ -210,6 +216,7 @@ jobs: target: "${{ matrix.test-mode }}" - name: Validate Unity license secrets + timeout-minutes: 2 uses: ./.github/actions/validate-unity-license env: UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} @@ -224,6 +231,7 @@ jobs: # carries the specs to the doc/comment jobs. The script exits 0 even on # failure, so a probe error never reds the benchmark leg. - name: Collect machine specs + timeout-minutes: 2 shell: pwsh run: | $artifactsPath = '.artifacts/unity/perf/${{ matrix.unity-version }}-${{ matrix.test-mode }}' @@ -234,6 +242,7 @@ jobs: - name: Acquire organization Unity lock id: acquire_lock + timeout-minutes: 305 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: lock-name: wallstop-organization-builds @@ -246,6 +255,7 @@ jobs: - name: Require acquired Unity lock if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} + timeout-minutes: 1 shell: pwsh run: exit 1 @@ -276,6 +286,7 @@ jobs: - name: Upload Unity provisioning diagnostics if: always() + timeout-minutes: 5 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: perf-provisioning-${{ matrix.unity-version }}-${{ matrix.test-mode }} @@ -289,7 +300,7 @@ jobs: # 180 (not 120): the Standalone leg builds a COLD IL2CPP player, which is # far slower than a Mono build. The PlayMode leg runs IN-EDITOR (Mono) with # no player build and is faster, so 180 is generous for it too. Stays - # strictly below the job-level timeout (660) so the step clock + # strictly below the job-level timeout (900) so the step clock # fires first and releases the shared Unity seat. Must stay >= the # validator's RUN_BUDGET (120). timeout-minutes: 180 @@ -326,6 +337,7 @@ jobs: - name: Capture exact standalone player size if: ${{ matrix.test-mode == 'standalone' && steps.run_tests.outcome == 'success' }} + timeout-minutes: 5 shell: pwsh run: | $playerDir = '.artifacts/unity/projects/${{ matrix.unity-version }}-standalone/Build/DxmTestPlayer' @@ -354,6 +366,7 @@ jobs: - name: Dump Unity log tail on failure or cancellation if: ${{ failure() || cancelled() }} + timeout-minutes: 5 uses: ./.github/actions/dump-unity-log-tail with: results-dir: .artifacts/unity/perf/${{ matrix.unity-version }}-${{ matrix.test-mode }} @@ -366,6 +379,7 @@ jobs: steps.compute.outcome == 'success' && (steps.compute.outputs.is-empty == 'true' || steps.run_tests.outcome != 'skipped') }} + timeout-minutes: 10 uses: ./.github/actions/verify-unity-results with: results-dir: .artifacts/unity/perf/${{ matrix.unity-version }}-${{ matrix.test-mode }} @@ -374,6 +388,7 @@ jobs: - name: Upload perf benchmark artifacts if: always() + timeout-minutes: 10 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: perf-${{ matrix.unity-version }}-${{ matrix.test-mode }} @@ -408,6 +423,7 @@ jobs: - name: Release organization Unity lock id: release_unity_lock if: always() + timeout-minutes: 5 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@0ce3dce6cbe29af210432087e3b6d81509258063 with: lock-name: wallstop-organization-builds @@ -422,6 +438,7 @@ jobs: - name: Require confirmed Unity cleanup if: always() + timeout-minutes: 2 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@0ce3dce6cbe29af210432087e3b6d81509258063 with: acquired: ${{ steps.acquire_lock.outputs.acquired }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d528e675..7ece1a39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -185,15 +185,17 @@ jobs: permissions: contents: read checks: write - timeout-minutes: 660 + timeout-minutes: 900 steps: - name: Enable Git long paths + timeout-minutes: 2 shell: pwsh env: GIT_CONFIG_GLOBAL: ${{ runner.temp }}/dxm-gitconfig run: git config --global core.longpaths true - name: Checkout + timeout-minutes: 30 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 env: GIT_CONFIG_GLOBAL: ${{ runner.temp }}/dxm-gitconfig @@ -202,6 +204,7 @@ jobs: persist-credentials: false - name: Print runner diagnostics + timeout-minutes: 2 # Composite action runs PowerShell internally; `shell: bash` on this # self-hosted Windows runner resolved to the WSL stub at # C:\Windows\System32\bash.exe and failed with "Windows Subsystem @@ -211,6 +214,7 @@ jobs: uses: ./.github/actions/print-self-hosted-runner-diagnostics - name: Setup Node.js + timeout-minutes: 5 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22.18.0" @@ -220,11 +224,13 @@ jobs: # all three Unity workflows; shell: pwsh is enforced inside the composite. - name: Compute test assembly list id: compute + timeout-minutes: 5 uses: ./.github/actions/compute-unity-assemblies with: target: editmode - name: Validate Unity license secrets + timeout-minutes: 2 uses: ./.github/actions/validate-unity-license env: UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} @@ -238,6 +244,7 @@ jobs: # never triggers; it is the robustness path for an empty target. - name: Acquire organization Unity lock id: acquire_lock + timeout-minutes: 305 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: lock-name: wallstop-organization-builds @@ -250,6 +257,7 @@ jobs: - name: Require acquired Unity lock if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} + timeout-minutes: 1 shell: pwsh run: exit 1 @@ -273,6 +281,7 @@ jobs: - name: Upload Unity provisioning diagnostics if: always() + timeout-minutes: 5 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: unity-release-provisioning-editmode @@ -309,6 +318,7 @@ jobs: # in the GitHub summary; matches unity-tests.yml / unity-benchmarks.yml. - name: Dump Unity log tail on failure or cancellation if: ${{ failure() || cancelled() }} + timeout-minutes: 5 uses: ./.github/actions/dump-unity-log-tail with: results-dir: .artifacts/unity/release-editmode @@ -328,6 +338,7 @@ jobs: steps.compute.outcome == 'success' && (steps.compute.outputs.is-empty == 'true' || steps.run_tests.outcome != 'skipped') }} + timeout-minutes: 10 uses: ./.github/actions/verify-unity-results with: results-dir: .artifacts/unity/release-editmode @@ -336,6 +347,7 @@ jobs: - name: Upload release Unity artifacts if: always() + timeout-minutes: 10 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: unity-release-editmode @@ -370,6 +382,7 @@ jobs: - name: Release organization Unity lock id: release_unity_lock if: always() + timeout-minutes: 5 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@0ce3dce6cbe29af210432087e3b6d81509258063 with: lock-name: wallstop-organization-builds @@ -384,6 +397,7 @@ jobs: - name: Require confirmed Unity cleanup if: always() + timeout-minutes: 2 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@0ce3dce6cbe29af210432087e3b6d81509258063 with: acquired: ${{ steps.acquire_lock.outputs.acquired }} @@ -414,15 +428,17 @@ jobs: runs-on: [self-hosted, Windows, RAM-64GB] permissions: contents: read - timeout-minutes: 660 + timeout-minutes: 900 steps: - name: Enable Git long paths + timeout-minutes: 2 shell: pwsh env: GIT_CONFIG_GLOBAL: ${{ runner.temp }}/dxm-gitconfig run: git config --global core.longpaths true - name: Checkout + timeout-minutes: 30 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 env: GIT_CONFIG_GLOBAL: ${{ runner.temp }}/dxm-gitconfig @@ -431,11 +447,13 @@ jobs: persist-credentials: false - name: Print runner diagnostics + timeout-minutes: 2 # Composite action runs PowerShell internally; never use plain # `shell: bash` on self-hosted Windows runners (see unity-checks). uses: ./.github/actions/print-self-hosted-runner-diagnostics - name: Setup Node.js + timeout-minutes: 5 # export-unitypackage.ps1 stages the payload with `npm pack`, so the # shipped bytes are identical to the npm/UPM artifact. uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -443,6 +461,7 @@ jobs: node-version: "22.18.0" - name: Validate Unity license secrets + timeout-minutes: 2 uses: ./.github/actions/validate-unity-license env: UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} @@ -451,6 +470,7 @@ jobs: - name: Acquire organization Unity lock id: acquire_lock + timeout-minutes: 305 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: lock-name: wallstop-organization-builds @@ -463,6 +483,7 @@ jobs: - name: Require acquired Unity lock if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} + timeout-minutes: 1 shell: pwsh run: exit 1 @@ -487,6 +508,7 @@ jobs: - name: Upload Unity provisioning diagnostics if: always() + timeout-minutes: 5 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: unity-release-provisioning-unitypackage @@ -520,12 +542,14 @@ jobs: - name: Dump Unity log tail on failure or cancellation if: ${{ failure() || cancelled() }} + timeout-minutes: 5 uses: ./.github/actions/dump-unity-log-tail with: results-dir: .artifacts/unity/release-unitypackage label: Unity release unitypackage export - name: Upload the .unitypackage artifact + timeout-minutes: 20 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-unitypackage @@ -536,6 +560,7 @@ jobs: - name: Upload export diagnostics if: always() + timeout-minutes: 10 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: unity-release-unitypackage @@ -571,6 +596,7 @@ jobs: - name: Release organization Unity lock id: release_unity_lock if: always() + timeout-minutes: 5 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@0ce3dce6cbe29af210432087e3b6d81509258063 with: lock-name: wallstop-organization-builds @@ -585,6 +611,7 @@ jobs: - name: Require confirmed Unity cleanup if: always() + timeout-minutes: 2 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@0ce3dce6cbe29af210432087e3b6d81509258063 with: acquired: ${{ steps.acquire_lock.outputs.acquired }} diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml index f90d716b..4f9c14d7 100644 --- a/.github/workflows/unity-benchmarks.yml +++ b/.github/workflows/unity-benchmarks.yml @@ -39,7 +39,7 @@ jobs: needs: - runner-preflight runs-on: [self-hosted, Windows, RAM-64GB] - timeout-minutes: 660 + timeout-minutes: 900 strategy: fail-fast: false max-parallel: 1 @@ -53,12 +53,14 @@ jobs: - playmode steps: - name: Enable Git long paths + timeout-minutes: 2 shell: pwsh env: GIT_CONFIG_GLOBAL: ${{ runner.temp }}/dxm-gitconfig run: git config --global core.longpaths true - name: Checkout + timeout-minutes: 30 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 env: GIT_CONFIG_GLOBAL: ${{ runner.temp }}/dxm-gitconfig @@ -67,6 +69,7 @@ jobs: persist-credentials: false - name: Print runner diagnostics + timeout-minutes: 2 # Composite action runs PowerShell internally; `shell: bash` on this # self-hosted Windows runner resolved to the WSL stub at # C:\Windows\System32\bash.exe and failed with "Windows Subsystem @@ -78,6 +81,7 @@ jobs: matrix-note: "default (organization lock wraps the Unity section)" - name: Cache Unity Library and package caches + timeout-minutes: 30 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 env: PACKAGE_HASH: >- @@ -97,6 +101,7 @@ jobs: key: Library-bench-${{ runner.os }}-${{ runner.arch }}-${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ env.PACKAGE_HASH }} - name: Setup Node.js + timeout-minutes: 5 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22.18.0" @@ -108,12 +113,14 @@ jobs: # shell: pwsh is enforced inside the composite. - name: Compute benchmark assembly list id: compute + timeout-minutes: 5 uses: ./.github/actions/compute-unity-assemblies with: include-perf: "true" target: "${{ matrix.test-mode }}" - name: Validate Unity license secrets + timeout-minutes: 2 uses: ./.github/actions/validate-unity-license env: UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} @@ -128,6 +135,7 @@ jobs: # is defense-in-depth and uniformity with the required workflows. - name: Acquire organization Unity lock id: acquire_lock + timeout-minutes: 305 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: lock-name: wallstop-organization-builds @@ -140,6 +148,7 @@ jobs: - name: Require acquired Unity lock if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} + timeout-minutes: 1 shell: pwsh run: exit 1 @@ -163,6 +172,7 @@ jobs: - name: Upload Unity provisioning diagnostics if: always() + timeout-minutes: 5 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: unity-benchmark-provisioning-${{ matrix.unity-version }}-${{ matrix.test-mode }} @@ -207,6 +217,7 @@ jobs: # before Unity launched). - name: Dump Unity log tail on failure or cancellation if: ${{ failure() || cancelled() }} + timeout-minutes: 5 uses: ./.github/actions/dump-unity-log-tail with: results-dir: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }} @@ -229,6 +240,7 @@ jobs: steps.compute.outcome == 'success' && (steps.compute.outputs.is-empty == 'true' || steps.run_tests.outcome != 'skipped') }} + timeout-minutes: 10 uses: ./.github/actions/verify-unity-results with: results-dir: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }} @@ -237,6 +249,7 @@ jobs: - name: Upload benchmark artifacts if: always() + timeout-minutes: 10 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: unity-benchmarks-${{ matrix.unity-version }}-${{ matrix.test-mode }} @@ -271,6 +284,7 @@ jobs: - name: Release organization Unity lock id: release_unity_lock if: always() + timeout-minutes: 5 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@0ce3dce6cbe29af210432087e3b6d81509258063 with: lock-name: wallstop-organization-builds @@ -285,6 +299,7 @@ jobs: - name: Require confirmed Unity cleanup if: always() + timeout-minutes: 2 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@0ce3dce6cbe29af210432087e3b6d81509258063 with: acquired: ${{ steps.acquire_lock.outputs.acquired }} diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index e417df2f..2f6a2d80 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -69,7 +69,7 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: [self-hosted, Windows, RAM-64GB] - timeout-minutes: 660 + timeout-minutes: 900 strategy: fail-fast: false max-parallel: 1 @@ -84,6 +84,7 @@ jobs: - standalone steps: - name: Require current PR head before setup + timeout-minutes: 2 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-current-pr-head@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: github-token: ${{ github.token }} @@ -91,12 +92,14 @@ jobs: expected-head-sha: ${{ github.event.pull_request.head.sha }} - name: Enable Git long paths + timeout-minutes: 2 shell: pwsh env: GIT_CONFIG_GLOBAL: ${{ runner.temp }}/dxm-gitconfig run: git config --global core.longpaths true - name: Checkout + timeout-minutes: 30 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 env: GIT_CONFIG_GLOBAL: ${{ runner.temp }}/dxm-gitconfig @@ -105,6 +108,7 @@ jobs: persist-credentials: false - name: Print runner diagnostics + timeout-minutes: 2 # Composite action runs PowerShell internally; `shell: bash` on this # self-hosted Windows runner resolved to the WSL stub at # C:\Windows\System32\bash.exe and failed with "Windows Subsystem @@ -116,6 +120,7 @@ jobs: matrix-note: "default (organization lock wraps the Unity section)" - name: Cache Unity Library and package caches + timeout-minutes: 30 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 env: PACKAGE_HASH: >- @@ -135,6 +140,7 @@ jobs: key: Library-${{ runner.os }}-${{ runner.arch }}-${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ env.PACKAGE_HASH }} - name: Setup Node.js + timeout-minutes: 5 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22.18.0" @@ -145,12 +151,14 @@ jobs: # inside the composite. - name: Compute test assembly list id: compute + timeout-minutes: 5 uses: ./.github/actions/compute-unity-assemblies with: target: "${{ matrix.test-mode }}" runtime-only: "${{ matrix.test-mode == 'standalone' && 'true' || 'false' }}" - name: Validate Unity license secrets + timeout-minutes: 2 uses: ./.github/actions/validate-unity-license env: UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} @@ -167,6 +175,7 @@ jobs: # "tests did not run" annotation for a Unity run that never started. - name: Require current PR head before lock acquisition if: ${{ steps.compute.outputs.is-empty != 'true' }} + timeout-minutes: 2 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-current-pr-head@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: github-token: ${{ github.token }} @@ -175,6 +184,7 @@ jobs: - name: Acquire organization Unity lock id: acquire_lock + timeout-minutes: 305 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/acquire-build-lock@3741b56ceab4a68ba4c09fe7e91e804b53ff2412 # v1.10.0 with: lock-name: wallstop-organization-builds @@ -190,6 +200,7 @@ jobs: - name: Require acquired Unity lock if: ${{ steps.acquire_lock.outputs.acquired != 'true' }} + timeout-minutes: 1 shell: pwsh run: exit 1 @@ -220,6 +231,7 @@ jobs: - name: Upload Unity provisioning diagnostics if: always() + timeout-minutes: 5 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: unity-provisioning-${{ matrix.unity-version }}-${{ matrix.test-mode }} @@ -237,7 +249,7 @@ jobs: # end-to-end and closer to shipped-player behavior. 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 + # timeout (900) so the step clock fires first and releases the # shared Unity seat. timeout-minutes: 150 shell: pwsh @@ -269,6 +281,7 @@ jobs: # before Unity launched). - name: Dump Unity log tail on failure or cancellation if: ${{ failure() || cancelled() }} + timeout-minutes: 5 uses: ./.github/actions/dump-unity-log-tail with: results-dir: .artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }} @@ -291,6 +304,7 @@ jobs: steps.compute.outcome == 'success' && (steps.compute.outputs.is-empty == 'true' || steps.run_tests.outcome != 'skipped') }} + timeout-minutes: 10 uses: ./.github/actions/verify-unity-results with: results-dir: .artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }} @@ -299,6 +313,7 @@ jobs: - name: Upload Unity test artifacts if: always() + timeout-minutes: 10 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: unity-${{ matrix.unity-version }}-${{ matrix.test-mode }} @@ -333,6 +348,7 @@ jobs: - name: Release organization Unity lock id: release_unity_lock if: always() + timeout-minutes: 5 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/release-build-lock@0ce3dce6cbe29af210432087e3b6d81509258063 with: lock-name: wallstop-organization-builds @@ -347,6 +363,7 @@ jobs: - name: Require confirmed Unity cleanup if: always() + timeout-minutes: 2 uses: Ambiguous-Interactive/ambiguous-organization-build-lock/.github/actions/require-confirmed-unity-cleanup@0ce3dce6cbe29af210432087e3b6d81509258063 with: acquired: ${{ steps.acquire_lock.outputs.acquired }} diff --git a/.llm/index.json b/.llm/index.json index 832f6370..a08894a4 100644 --- a/.llm/index.json +++ b/.llm/index.json @@ -431,7 +431,7 @@ "category": "unity", "tags": "unity, ci, matrix, il2cpp, lts, game-ci" }, - "lineCount": 123, + "lineCount": 125, "references": [ ".llm/skills/unity-editor-ci/references/github-actions-version-consistency.md", ".llm/skills/unity-editor-ci/references/unity-ci-matrix.md", diff --git a/.llm/skills/unity-editor-ci/SKILL.md b/.llm/skills/unity-editor-ci/SKILL.md index 57607c92..006ddb30 100644 --- a/.llm/skills/unity-editor-ci/SKILL.md +++ b/.llm/skills/unity-editor-ci/SKILL.md @@ -46,15 +46,17 @@ Windows runners. `unity-tests.yml` is one unified matrix of three Unity versions protect it: `strategy.max-parallel: 1` serializes cells WITHIN a run, and the external `Ambiguous-Interactive/ambiguous-organization-build-lock` actions admit at most two runners ACROSS runs and repositories. -- `max-parallel: 1` goes under `strategy:` on the matrix workflows only (`unity-tests.yml`, - `unity-benchmarks.yml`). A native `concurrency.group: wallstop-organization-builds` is - repository-scoped and is FORBIDDEN. -- Timeout invariant: `job timeout-minutes >= acquire timeout-minutes + RUN_BUDGET (120)`. - Current magnitudes: acquire `timeout-minutes: "300"`, job `timeout-minutes: 420`, and a - step-level `timeout-minutes: 120` on the Unity run step. The step guard must be `>= 120` and - STRICTLY below the job timeout so the step fails first and releases the seat. -- Acquire the lock immediately before `run-ci-tests.ps1` and release it with `if: always()`. - Provision editors BEFORE taking the lock; provisioning can take tens of minutes. +- `max-parallel: 1` goes under `strategy:` on the matrix workflows (`unity-tests.yml`, + `unity-benchmarks.yml`, and `perf-numbers.yml`). A native + `concurrency.group: wallstop-organization-builds` is repository-scoped and is FORBIDDEN. +- Timeout invariant: every step before and including the cleanup gate has an explicit positive + timeout. The acquire step cap (`305`) exceeds its internal wait (`300`), provisioning is + capped at `180`, licensed work is capped at `120` to `180`, and cleanup uses `5`/`2`/`5`/`2` + for return/classify/release/gate. The `900`-minute job cap must retain at least 60 minutes + beyond the sum of those enforced step caps. +- Acquire the lock before editor provisioning and release it with `if: always()`. Provisioning + mutates the shared tool-cache root, so it belongs inside the same lock window as licensed + work. ### The compute-unity-assemblies is-empty gate diff --git a/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md b/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md index 18eaa711..2f943ebc 100644 --- a/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md +++ b/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md @@ -61,15 +61,17 @@ The Unity serial has two activation seats shared across the organization and no With both controls, a run consumes at most one seat while another repository can use the second. This is `max-parallel: 1` ONLY -- it is NOT a native concurrency group. A native `concurrency.group: wallstop-organization-builds` is repository-scoped, serializes whole jobs, and is forbidden. Add `max-parallel: 1` under `strategy:` (sibling of `fail-fast`/`matrix`) on the matrix workflows (`unity-tests.yml`, `unity-benchmarks.yml`, `perf-numbers.yml`); single-job release workflows rely on the lock for cross-run admission. -**Timeout invariant.** GitHub counts the lock-wait against the job clock, so a job at the back of the serialized queue is killed before its lock wait can finish unless: +**Timeout invariant.** GitHub counts the lock wait and every subsequent step against the job clock. A job-level timeout cancels the whole job, so later `if: always()` cleanup cannot run if an earlier uncapped step consumes the remaining clock. Every step before and including `Require confirmed Unity cleanup` therefore has an explicit positive timeout. ```text -job timeout-minutes >= acquire timeout-minutes + RUN_BUDGET (120) +job timeout-minutes >= sum(all capped steps through cleanup gate) + 60 ``` -The acquire input `timeout-minutes` is the lock POLL budget (how long the action waits for the seat), counted against the job clock. The current magnitudes are: acquire `timeout-minutes: "300"`, job `timeout-minutes: 420`, and a step-level `timeout-minutes: 120` on the Unity run step. Keep these magnitudes aligned across the Unity workflows when editing timeouts. +The acquire input `timeout-minutes: "300"` is the internal lock-poll budget. Its enclosing step has `timeout-minutes: 305`, so the action can finish and report a timeout before GitHub terminates the step. Editor provisioning is capped at 180 minutes, licensed work at 120 to 180 minutes, and return/classify/release/gate at 5/2/5/2 minutes. The licensed jobs use `timeout-minutes: 900`. `scripts/validate-unity-pr-policy.py` sums every enforced step cap through the cleanup gate and requires at least 60 minutes of remaining job time. -The step-level `timeout-minutes: 120` protects the in-use seat from a hung editor: it must be `>= 120` and STRICTLY below the job timeout so the step fails first and releases the lock instead of the whole job being cancelled with the seat still held. This step guard matters because `stuck-job-watchdog.yml` ignores any `in_progress` job -- nothing else bounds a post-acquire hang, so without the step timeout a wedged editor would squat the seat for the full job timeout. +The step-level caps protect the in-use seat from a hung editor or ancillary action. They must remain strictly below the job timeout so the step fails first and the cleanup chain still runs. This matters because `stuck-job-watchdog.yml` ignores any `in_progress` job; without step caps, a wedged action can squat the seat until the whole job is cancelled. + +Acquire the organization lock before provisioning. All licensed workflows install or repair editors under the shared `RUNNER_TOOL_CACHE/u6-v3` root, so provisioning must be serialized with licensed work. Release the lock only after the always-run return and cleanup-classification steps. **Operator note (standalone IL2CPP):** the `standalone` cells require the Windows IL2CPP Unity module and the host build toolchain needed by Unity for Windows players. `scripts/unity/ensure-editor.ps1` must be called with an explicit provisioning profile: `EditorOnly` for editmode/playmode/benchmarks/release checks, and `StandaloneWindowsIl2Cpp` for standalone so only `windows-il2cpp` is installed or verified. That script treats the beta standalone Unity CLI as a moving surface: it discovers the install root through the 0-arg `unity install-path` GETTER and sets the path best-effort, so an uncertain flag never aborts the matrix. See [unity-editor-cli-bootstrap](./unity-editor-cli-bootstrap.md) for the getter-vs-setter detail. diff --git a/progress/session-177-safe-editor-capture-proof.md b/progress/session-177-safe-editor-capture-proof.md index 42b415de..68cbadcd 100644 --- a/progress/session-177-safe-editor-capture-proof.md +++ b/progress/session-177-safe-editor-capture-proof.md @@ -207,8 +207,23 @@ That integration raised JavaScript source LOC to 17,565 against the 17,500 budget. A table-driven consolidation in `scripts/__tests__/ci-aggregate-workflow.test.js` preserves the workflow contracts while removing repeated assertion scaffolding and reduces the total -to 17,485 after synchronizing the three newer PR-head commits. The focused -aggregate-workflow suite passes all 17 data-driven tests, and the +to 17,485 after synchronizing the three newer PR-head commits. A final Cursor +review then found that the 660-minute licensed-job cap could be exhausted by the +300-minute lock wait, 180-minute editor provisioning, and 180-minute licensed +work limits before cleanup ran. The first 750-minute correction still left +uncapped ancillary steps able to consume the job clock. All five lock windows +now cap every step through the cleanup gate, give the acquire step 305 minutes +for its 300-minute internal wait, and use a 900-minute job cap. Their bounded +totals range from 696 to 793 minutes, leaving 107 to 204 minutes for GitHub +runner overhead. The positional Python policy validator inspects every actual +top-level step through the cleanup gate, so duplicate names and steps inserted +between cleanup stages cannot bypass the budget. Its data-driven contract +rejects missing, malformed, or zero timeouts and requires every window to retain +at least 60 minutes beyond the complete bounded lifecycle. Restoring the +JavaScript test tables to their readable form leaves the final JavaScript total +at 17,486. + +The focused aggregate-workflow suite passes all 17 data-driven tests, and the heartbeat probe passes all 37 assertions. The final full Node suite passes 406 tests with zero failures. `npm run validate:all`, strict documentation build, Actionlint, Yamllint, PowerShell parsing, and `git diff --check` also pass. The @@ -220,7 +235,7 @@ retained at ## Remaining work -- Push the committed branch and obtain current reviews and required checks. +- Obtain current reviews and required checks for the timeout-budget fix. - Switch the host to Personal/light through the Unity UI. - Resolve the manifest's Unity 2022.3 LTS requirement versus the configured 6000.4.6f1 host. diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index 6fbb5fe6..65e8b5c8 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -38,6 +38,7 @@ const [LOCK_ACTION_PIN, CLEANUP_POLICY_PIN] = [["check-unity-runner-availability const LOCK_ACTION_SHA = LOCK_ACTION_PIN.sha; const ACQUIRE_ACTION_SHA = LOCK_ACTION_PIN.sha; const CLEANUP_POLICY_SHA = CLEANUP_POLICY_PIN.sha; +// SYNC: Keep scripts/validate-unity-pr-policy.py LICENSED_LOCK_WINDOWS aligned. const UNITY_LOCK_WINDOWS = [ ["unity-tests.yml", "unity-tests", "Run Unity Test Runner", true], ["unity-benchmarks.yml", "benchmarks", "Run Unity Test Runner", true], @@ -453,9 +454,9 @@ test("every Unity lock window releases with explicit cleanup proof", () => { [returnStep, /\n id: return_unity_license\n if: \$\{\{ always\(\) && steps\.acquire_lock\.outputs\.acquired == 'true' \}\}\n/], [classifyStep, /\n id: cleanup_classification\n if: \$\{\{ always\(\) && steps\.acquire_lock\.outputs\.acquired == 'true' \}\}\n/], [classifyStep, /return-log-digest: \$\{\{ steps\.return_unity_license\.outputs\.return-log-digest \}\}/], - [releaseStep, /\n id: release_unity_lock\n if: always\(\)\n/], + [releaseStep, /\n id: release_unity_lock\n if: always\(\)\n timeout-minutes: 5\n/], [releaseStep, /resource-cleanup-status: \$\{\{ steps\.cleanup_classification\.outputs\.resource-cleanup-status \}\}/], - [gateStep, /\n if: always\(\)\n/], + [gateStep, /\n if: always\(\)\n timeout-minutes: 2\n/], [gateStep, /classification-complete: \$\{\{ steps\.cleanup_classification\.outputs\.classification-complete \}\}/], [gateStep, /release-outcome: \$\{\{ steps\.release_unity_lock\.outcome \}\}/], ...(file === "unity-tests.yml" ? [[workStep, /-UnityInstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, `${label}: licensed work and central return must use the same trusted editor root`]] : []) diff --git a/scripts/validate-unity-pr-policy.py b/scripts/validate-unity-pr-policy.py index 635e3676..eec80ebb 100644 --- a/scripts/validate-unity-pr-policy.py +++ b/scripts/validate-unity-pr-policy.py @@ -19,6 +19,15 @@ ".github/workflows/unity-benchmarks.yml", ".github/workflows/unity-tests.yml", } +# SYNC: Keep scripts/__tests__/ci-aggregate-workflow.test.js UNITY_LOCK_WINDOWS aligned. +LICENSED_LOCK_WINDOWS = ( + (Path(".github/workflows/unity-tests.yml"), "unity-tests"), + (Path(".github/workflows/unity-benchmarks.yml"), "benchmarks"), + (Path(".github/workflows/perf-numbers.yml"), "perf-benchmarks"), + (Path(".github/workflows/release.yml"), "unity-checks"), + (Path(".github/workflows/release.yml"), "unitypackage"), +) +UNITY_LIFECYCLE_OVERHEAD_RESERVE_MINUTES = 60 UNITY_CREDENTIAL_OR_ACTIVATION = re.compile( r"\bUNITY_(?:SERIAL|EMAIL|PASSWORD|LICENSE|LICENSING_SERVER)\b|" r"game-ci/unity-(?:test-runner|builder|activate)@", @@ -102,6 +111,82 @@ def step_block(job: str, name: str) -> str: return job[start : following if following >= 0 else len(job)] +def top_level_steps_through_cleanup_gate(job: str, label: str) -> list[str]: + steps_start = job.find(" steps:\n") + require(steps_start >= 0, f"{label}: missing steps") + starts = [ + match.start() + for match in re.finditer(r"^ -(?: |$)", job[steps_start:], re.MULTILINE) + ] + starts = [steps_start + start for start in starts] + require(bool(starts), f"{label}: missing top-level steps") + blocks = [ + job[start : starts[index + 1] if index + 1 < len(starts) else len(job)] + for index, start in enumerate(starts) + ] + gate_indexes = [ + index + for index, block in enumerate(blocks) + if block.startswith(" - name: Require confirmed Unity cleanup\n") + ] + require( + len(gate_indexes) == 1, + f"{label}: expected one confirmed-cleanup gate, found {len(gate_indexes)}", + ) + return blocks[: gate_indexes[0] + 1] + + +def positive_timeout(source: str, indentation: int, label: str) -> int: + matches = re.findall( + rf"^{' ' * indentation}timeout-minutes: ([1-9]\d*)[ \t]*$", + source, + re.MULTILINE, + ) + require( + len(matches) == 1, + f"{label}: expected one positive integer timeout, found {matches}", + ) + return int(matches[0]) + + +def validate_lock_window_timeout_budget(job: str, label: str) -> None: + steps = top_level_steps_through_cleanup_gate(job, label) + bounded_minutes = 0 + for index, step in enumerate(steps, start=1): + name = re.search(r"^ - name: (.+)$", step, re.MULTILINE) + step_label = name.group(1) if name else f"step {index}" + bounded_minutes += positive_timeout(step, 8, f"{label}:{step_label}") + + acquire_steps = [step for step in steps if f"uses: {ACQUIRE_BUILD_LOCK}" in step] + require( + len(acquire_steps) == 1, + f"{label}: expected one acquire step, found {len(acquire_steps)}", + ) + acquire = acquire_steps[0] + acquire_wait = re.findall( + r'^ timeout-minutes: "([1-9]\d*)"[ \t]*$', + acquire, + re.MULTILINE, + ) + require( + len(acquire_wait) == 1, + f"{label}: expected one positive acquire wait, found {acquire_wait}", + ) + require( + positive_timeout(acquire, 8, f"{label}:acquire step") > int(acquire_wait[0]), + f"{label}: acquire step timeout must exceed its internal wait", + ) + + job_timeout = positive_timeout(job, 4, f"{label}:job") + require( + job_timeout + >= bounded_minutes + UNITY_LIFECYCLE_OVERHEAD_RESERVE_MINUTES, + f"{label}: job timeout {job_timeout} must reserve at least " + f"{UNITY_LIFECYCLE_OVERHEAD_RESERVE_MINUTES} minutes beyond the " + f"{bounded_minutes}-minute bounded lifecycle", + ) + + def validate_licensed_workflow_policy(source: str) -> str: concurrency = re.search( r"^concurrency:\n(?P(?:^ .+\n)+)", @@ -188,6 +273,54 @@ def repository_unity_automation(github: Path = Path(".github")) -> dict[str, str def validate() -> None: + timeout_fixture = f""" fixture: + timeout-minutes: 70 + steps: + - name: Acquire organization Unity lock + timeout-minutes: 5 + uses: {ACQUIRE_BUILD_LOCK} + with: + timeout-minutes: "4" + - name: Return Unity license + timeout-minutes: 1 + - name: Require confirmed Unity cleanup + timeout-minutes: 2 +""" + validate_lock_window_timeout_budget(timeout_fixture, "timeout fixture") + for name, mutation in ( + ( + "duplicate named step", + timeout_fixture.replace( + " - name: Require confirmed Unity cleanup\n", + " - name: Return Unity license\n" + " - name: Require confirmed Unity cleanup\n", + ), + ), + ( + "inserted cleanup step", + timeout_fixture.replace( + " - name: Require confirmed Unity cleanup\n", + " - uses: example/unbounded@immutable\n" + " - name: Require confirmed Unity cleanup\n", + ), + ), + ( + "dash-only cleanup step", + timeout_fixture.replace( + " - name: Require confirmed Unity cleanup\n", + " -\n" + " id: unbounded\n" + " run: exit 0\n" + " - name: Require confirmed Unity cleanup\n", + ), + ), + ): + try: + validate_lock_window_timeout_budget(mutation, name) + except AssertionError: + continue + raise AssertionError(f"{name}: unbounded step was accepted") + parser_fixture = """ - name: Fixture run: | echo first @@ -281,6 +414,11 @@ def validate() -> None: "unregistered credential-bearing or activation-capable Unity automation: " + ", ".join(unregistered), ) + for workflow, job_id in LICENSED_LOCK_WINDOWS: + validate_lock_window_timeout_budget( + job_block(workflow.read_text(encoding="utf-8"), job_id), + f"{workflow}:{job_id}", + ) source = WORKFLOW.read_text(encoding="utf-8") licensed = validate_licensed_workflow_policy(source) From 3f28a5e41b45289b8137528277d5e34d5ab959ac Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 30 Jul 2026 21:38:09 +0000 Subject: [PATCH 21/22] Make Unity workflows validation-only --- .agents/skills/unity-editor-ci/SKILL.md | 17 +- .claude/skills/unity-editor-ci/SKILL.md | 17 +- .../assert-unity-host-prereqs/action.yml | 95 +- .../compute-unity-assemblies/action.yml | 2 +- .../action.yml | 14 +- .../actions/verify-unity-results/action.yml | 32 +- .github/workflows/perf-numbers.yml | 60 +- .github/workflows/release.yml | 102 +- .github/workflows/runner-bootstrap.yml | 139 +-- .github/workflows/unity-benchmarks.yml | 57 +- .github/workflows/unity-tests.yml | 73 +- .llm/context.md | 2 +- .llm/index.json | 4 +- .llm/index.md | 2 +- .llm/skills/unity-editor-ci/SKILL.md | 31 +- .../references/unity-ci-matrix.md | 14 +- .../references/unity-editor-cli-bootstrap.md | 14 +- .../references/unity-runner-host-prereqs.md | 55 +- docs/ops/ci-and-github-settings.md | 14 +- docs/runbooks/unity-runners-after-transfer.md | 149 ++- .../__tests__/ci-aggregate-workflow.test.js | 32 +- scripts/unity/bootstrap-windows-runner.ps1 | 17 +- scripts/unity/ensure-editor.ps1 | 94 +- scripts/unity/export-unitypackage.ps1 | 2 +- scripts/unity/maintain-windows-runner.ps1 | 10 +- scripts/unity/run-ci-tests.ps1 | 2 +- scripts/validate-unity-pr-policy.py | 1069 +++++++++++++++++ 27 files changed, 1537 insertions(+), 582 deletions(-) diff --git a/.agents/skills/unity-editor-ci/SKILL.md b/.agents/skills/unity-editor-ci/SKILL.md index b84f821d..96d2c6f4 100644 --- a/.agents/skills/unity-editor-ci/SKILL.md +++ b/.agents/skills/unity-editor-ci/SKILL.md @@ -1,15 +1,14 @@ --- name: unity-editor-ci description: "Unity CI on self-hosted Windows runners: the unity-tests.yml - matrix of 3 Unity versions x {editmode, playmode, standalone}, the canonical - version list in .github/unity-versions.json enforced by npm run - validate:unity-versions, the organization build-lock and timeout invariants - that protect the two-seat Unity serial, the ensure-editor.ps1 standalone-CLI - bootstrap with its PATH refresh and module quarantine/reinstall repair, - Windows host prerequisites for 0xC0000135 startup failures, and repo-wide - GitHub Action version pins. Use when bumping a Unity version, adding a matrix - cell, triaging an IL2CPP-only or license or editor-provisioning failure, or - editing a Unity workflow." + matrix of 3 Unity versions x {editmode, playmode, standalone}, manual + administrator installation under RUNNER_TOOL_CACHE/u6-v3, validation-only + workflow checks with ensure-editor.ps1 -RequireHealthyExisting, the + organization build-lock and timeout invariants that protect the two-seat Unity + serial, Windows host prerequisites for 0xC0000135 startup failures, and + repo-wide GitHub Action version pins. Use when bumping a Unity version, adding + a matrix cell, triaging an IL2CPP-only, license, or editor-validation failure, + or editing a Unity workflow." --- diff --git a/.claude/skills/unity-editor-ci/SKILL.md b/.claude/skills/unity-editor-ci/SKILL.md index b84f821d..96d2c6f4 100644 --- a/.claude/skills/unity-editor-ci/SKILL.md +++ b/.claude/skills/unity-editor-ci/SKILL.md @@ -1,15 +1,14 @@ --- name: unity-editor-ci description: "Unity CI on self-hosted Windows runners: the unity-tests.yml - matrix of 3 Unity versions x {editmode, playmode, standalone}, the canonical - version list in .github/unity-versions.json enforced by npm run - validate:unity-versions, the organization build-lock and timeout invariants - that protect the two-seat Unity serial, the ensure-editor.ps1 standalone-CLI - bootstrap with its PATH refresh and module quarantine/reinstall repair, - Windows host prerequisites for 0xC0000135 startup failures, and repo-wide - GitHub Action version pins. Use when bumping a Unity version, adding a matrix - cell, triaging an IL2CPP-only or license or editor-provisioning failure, or - editing a Unity workflow." + matrix of 3 Unity versions x {editmode, playmode, standalone}, manual + administrator installation under RUNNER_TOOL_CACHE/u6-v3, validation-only + workflow checks with ensure-editor.ps1 -RequireHealthyExisting, the + organization build-lock and timeout invariants that protect the two-seat Unity + serial, Windows host prerequisites for 0xC0000135 startup failures, and + repo-wide GitHub Action version pins. Use when bumping a Unity version, adding + a matrix cell, triaging an IL2CPP-only, license, or editor-validation failure, + or editing a Unity workflow." --- diff --git a/.github/actions/assert-unity-host-prereqs/action.yml b/.github/actions/assert-unity-host-prereqs/action.yml index 7aa99373..7796e5c3 100644 --- a/.github/actions/assert-unity-host-prereqs/action.yml +++ b/.github/actions/assert-unity-host-prereqs/action.yml @@ -1,71 +1,34 @@ name: Assert Unity host prerequisites description: >- - Ensure the self-hosted Windows runner has every host-OS prerequisite Unity - Editor needs to launch successfully (Microsoft Visual C++ 2015-2022 x64 - Redistributable, Windows long-path support, Windows Defender exclusions, - PowerShell 7, UCRT). Root cause: Unity.exe failed at startup with + Verify the self-hosted Windows runner has every host-OS prerequisite Unity + Editor needs to launch successfully (Microsoft Visual C++ 2010 SP1 and + 2015-2022 x64 Redistributables, Windows long-path support, Windows Defender + exclusions, PowerShell 7, UCRT). Root cause: Unity.exe failed at startup with -1073741515 / 0xC0000135 (STATUS_DLL_NOT_FOUND) on DAD-MACHINE because the VC++ redist was missing. GitHub-hosted windows-2022 ships the redist preinstalled; self-hosted runners do not. This action runs the bootstrap - script (auto-install by default) so every Unity job is preceded by a - resilient prereq check. Non-Windows callers (Linux/macOS) are no-ops -- - the action emits a friendly ::notice:: skip and exits 0. - - MODE RESOLUTION PRECEDENCE (highest wins): - 1. Non-Windows host -> ::notice:: skip, exit 0 - 2. DXM_RUNNER_DISABLE_AUTO_BOOTSTRAP=1 -> force DetectOnly - 3. inputs.auto-install is NOT truthy -> DetectOnly - 4. inputs.auto-install IS truthy (default) -> auto-install - Truthy normalization (F4): true/True/1/yes/y all accepted; anything else - (including blank) is treated as false. Operators relying on the env-var - override see the same precedence rule on the workflow path (see - .github/workflows/runner-bootstrap.yml). - -inputs: - auto-install: - description: >- - If truthy (default 'true'; accepts true/True/1/yes/y), bootstrap - auto-installs any missing prereq. Otherwise the script runs with - -DetectOnly and fails fast (exit 2) on any missing prereq. The env var - DXM_RUNNER_DISABLE_AUTO_BOOTSTRAP=1 forces DetectOnly regardless of - this input (operator escape hatch). - required: false - default: "true" + script with -DetectOnly. It never installs or repairs host software because + GitHub Actions does not have administrator credentials. This composite is + Windows-only because its first guard uses Windows PowerShell 5.1. runs: using: composite steps: # F14: PowerShell 5.1 preflight FIRST. The rest of the composite uses # `shell: pwsh`; without this step a runner missing pwsh would fail - # with the cryptic "pwsh: command not found" before the bootstrap - # script gets a chance to install pwsh. Use Windows PowerShell 5.1 - # (always built into Windows runners) so it executes regardless of - # whether pwsh exists. If pwsh is missing, point the operator at the - # runner-bootstrap.yml workflow_dispatch (which intentionally avoids - # `shell: pwsh` and CAN install pwsh; see that workflow's header for - # the chicken-and-egg rationale). + # with the cryptic "pwsh: command not found". Use Windows PowerShell 5.1 + # (always built into Windows runners) so it can report the manual fix. - name: Verify PowerShell 7 (pwsh) is available shell: powershell run: | - # Skip the preflight on non-Windows hosts (e.g. Linux callers that - # consume this composite by accident): pwsh is the default shell - # there, Windows PowerShell 5.1 does not exist, and the platform - # gate inside the main step is what actually emits the skip notice. - if ([System.IO.Path]::DirectorySeparatorChar -ne '\') { - Write-Output "::notice::PS 5.1 preflight: skipping (not Windows: $env:RUNNER_OS)" - exit 0 - } if ($null -eq (Get-Command pwsh -ErrorAction SilentlyContinue)) { - Write-Output ("::error title=pwsh missing on self-hosted runner::PowerShell 7 (pwsh) is not installed on runner '$env:RUNNER_NAME'. Dispatch " + - ".github/workflows/runner-bootstrap.yml from the Actions UI to install it (the workflow uses Windows PowerShell 5.1 so it works even on a host " + - "missing pwsh). See docs/runbooks/unity-runners-after-transfer.md for the PowerShell 7 prerequisite.") + Write-Output ("::error title=pwsh missing on self-hosted runner::PowerShell 7 (pwsh) is not installed on runner '$env:RUNNER_NAME'. " + + "A runner administrator must install it manually. See docs/runbooks/unity-runners-after-transfer.md for the PowerShell 7 prerequisite.") exit 1 } Write-Output "pwsh found at: $((Get-Command pwsh).Source)" - - name: Probe + (optionally) install Unity host prerequisites + - name: Probe Unity host prerequisites shell: pwsh - env: - DXM_PREREQ_AUTO_INSTALL: ${{ inputs.auto-install }} run: | Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' @@ -85,27 +48,6 @@ runs: exit 0 } - # Operator escape hatch: DXM_RUNNER_DISABLE_AUTO_BOOTSTRAP=1 forces - # -DetectOnly regardless of the input. Use it when an operator wants - # to inspect prereq state from a Unity job without mutating the - # runner host. The input 'auto-install' is the workflow-author-facing - # control; the env var is the operator-facing override. - # F4: normalize the input via natural truthy parsing so 'True', - # ' true ', '1', 'yes', 'y' all map to auto-install; anything else - # falls back to DetectOnly. The string-equality compare against the - # literal 'true' would case-sensitively miss those equivalents. - $rawInput = '' + $env:DXM_PREREQ_AUTO_INSTALL - $normalizedInput = $rawInput.Trim().ToLowerInvariant() - $autoInstall = ($normalizedInput -eq 'true' -or $normalizedInput -eq '1' -or $normalizedInput -eq 'yes' -or $normalizedInput -eq 'y') - $detectOnly = $false - if ($env:DXM_RUNNER_DISABLE_AUTO_BOOTSTRAP -eq '1') { - Write-Host "::notice::assert-unity-host-prereqs: DXM_RUNNER_DISABLE_AUTO_BOOTSTRAP=1 -> forcing DetectOnly" - $detectOnly = $true - } elseif (-not $autoInstall) { - Write-Host "::notice::assert-unity-host-prereqs: auto-install='$rawInput' (normalized='$normalizedInput') -> DetectOnly" - $detectOnly = $true - } - # F6: resolve via $GITHUB_WORKSPACE directly. Workflows always do # `actions/checkout` before invoking this composite (it's a required # precondition because the composite needs the bootstrap script), @@ -128,12 +70,9 @@ runs: exit 1 } - Write-Host "::notice::assert-unity-host-prereqs: invoking $scriptPath (DetectOnly=$detectOnly)" - if ($detectOnly) { - & $scriptPath -DetectOnly - } else { - & $scriptPath - } + $unityInstallRoot = Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3' + Write-Host "::notice::assert-unity-host-prereqs: invoking $scriptPath (DetectOnly=True, UnityInstallRoot=$unityInstallRoot)" + & $scriptPath -DetectOnly -UnityInstallRoot $unityInstallRoot $code = $LASTEXITCODE $sw.Stop() $elapsed = [int]$sw.Elapsed.TotalSeconds @@ -146,9 +85,9 @@ runs: # unset it falls back to the default "missing VC++ Redistributable # is the most likely cause". Without this producer, the consumer # branch is dead code and the annotation is misleading whenever the - # preflight just installed/confirmed VC++ but Unity still fails + # preflight just confirmed VC++ but Unity still fails # 0xC0000135 for a different reason. Set on ANY exit-0 from the - # bootstrap (install path OR DetectOnly: both mean "host is OK"). + # bootstrap validation (exit 0 means "host is OK"). # Skip when GITHUB_ENV is missing (local invocation outside Actions). if ($code -eq 0 -and -not [string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) { Add-Content -Path $env:GITHUB_ENV -Value "DXM_RUNNER_PREREQ_INSTALLED=1" -Encoding utf8 diff --git a/.github/actions/compute-unity-assemblies/action.yml b/.github/actions/compute-unity-assemblies/action.yml index ad2dc233..d563315b 100644 --- a/.github/actions/compute-unity-assemblies/action.yml +++ b/.github/actions/compute-unity-assemblies/action.yml @@ -57,7 +57,7 @@ runs: # SKIP, do not fail: the discovery succeeded but no DxMessaging-owned # test assembly matched this target (for example a runtime-only # standalone run when every DxMessaging test asmdef is editor-only). - # The caller gates its provision/run/verify steps on is-empty so the + # The caller gates its validate/run/verify steps on is-empty so the # target is skipped cleanly instead of failing with "0 tests ran". Write-Host "::notice::No DxMessaging test assemblies matched this target; the Unity run will be skipped." Add-Content -Path $env:GITHUB_OUTPUT -Value "assemblies=" -Encoding utf8 diff --git a/.github/actions/print-self-hosted-runner-diagnostics/action.yml b/.github/actions/print-self-hosted-runner-diagnostics/action.yml index 66e3d3e6..075b96bf 100644 --- a/.github/actions/print-self-hosted-runner-diagnostics/action.yml +++ b/.github/actions/print-self-hosted-runner-diagnostics/action.yml @@ -171,16 +171,8 @@ runs: } # Assert Unity host prerequisites (VC++ redist, long-paths, Defender # exclusions, pwsh, UCRT). Unity test jobs are detect-only: host/editor - # repair belongs to scripts/unity/maintain-windows-runner.ps1 or the - # operator-dispatched runner-bootstrap workflow. - # F8: the assert-unity-host-prereqs composite's own non-Windows skip - # ("if DirectorySeparatorChar -ne '\' -> ::notice:: + exit 0") is - # DEFENSE-IN-DEPTH only -- this parent composite is itself Windows- - # only by contract (every consumer pins `runs-on: [self-hosted, - # Windows, ...]`), so the inner skip never fires in production. The - # belt-and-suspenders gate stays because a future reuse of - # assert-unity-host-prereqs from a non-Windows context must remain a - # no-op rather than throw. + # repair requires a runner administrator working directly on the host. + # This parent and the prerequisite composite are Windows-only by contract. # See scripts/unity/bootstrap-windows-runner.ps1 for the full prereq # list and remediation logic; this step exists because Unity.exe # failed with STATUS_DLL_NOT_FOUND (0xC0000135) on DAD-MACHINE when @@ -188,5 +180,3 @@ runs: # Unity installs (the missing DLL is on the OS, not in Unity). - name: Assert Unity host prerequisites uses: ./.github/actions/assert-unity-host-prereqs - with: - auto-install: "false" diff --git a/.github/actions/verify-unity-results/action.yml b/.github/actions/verify-unity-results/action.yml index ebd9f8e3..e1d72652 100644 --- a/.github/actions/verify-unity-results/action.yml +++ b/.github/actions/verify-unity-results/action.yml @@ -1,7 +1,7 @@ name: Verify Unity tests actually ran 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 + failed = 0. When XML is absent, list editor-validation diagnostics and scan the Unity log for catastrophic compile-time failures. inputs: results-dir: @@ -49,7 +49,7 @@ runs: Write-Host "::notice::No DxMessaging test assemblies were selected for $label; the Unity run was skipped, so there is nothing to verify." exit 0 } - function Write-ProvisioningDiagnosticsHint { + function Write-EditorValidationDiagnosticsHint { param([Parameter(Mandatory = $true)][string]$ResultsDir) $roots = New-Object System.Collections.Generic.List[string] @@ -64,19 +64,19 @@ runs: $dirs = New-Object System.Collections.Generic.List[string] foreach ($root in $roots) { - if ((Split-Path -Leaf $root) -ieq 'provisioning') { + if ((Split-Path -Leaf $root) -ieq 'editor-validation') { if (-not $dirs.Contains($root)) { $dirs.Add($root) } } - $direct = Join-Path $root 'provisioning' + $direct = Join-Path $root 'editor-validation' 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' } | + Where-Object { $_.Name -ieq 'editor-validation' } | ForEach-Object { if (-not $dirs.Contains($_.FullName)) { $dirs.Add($_.FullName) @@ -85,13 +85,13 @@ runs: } if ($dirs.Count -lt 1) { - Write-Host "::warning::No provisioning diagnostics directory found under $ResultsDir, .artifacts, or artifact." + Write-Host "::warning::No editor-validation diagnostics directory found under $ResultsDir, .artifacts, or artifact." return } - Write-Host "::group::Provisioning diagnostics files" + Write-Host "::group::Editor validation diagnostics files" foreach ($diagnosticsDir in $dirs) { - Write-Host "Provisioning diagnostics directory: $diagnosticsDir" + Write-Host "Editor validation diagnostics directory: $diagnosticsDir" $files = @( Get-ChildItem -LiteralPath $diagnosticsDir -File -Recurse -ErrorAction SilentlyContinue | Sort-Object FullName @@ -109,7 +109,7 @@ runs: Where-Object { $_.Name -match '(?i)summary' } | Select-Object -First 1 if ($summary) { - Write-Host "::notice::Provisioning summary: $($summary.FullName)" + Write-Host "::notice::Editor validation summary: $($summary.FullName)" try { $summaryJson = Get-Content -LiteralPath $summary.FullName -Raw | ConvertFrom-Json $classification = if ($summaryJson.finalClassification) { @@ -122,10 +122,10 @@ runs: } else { '(missing)' } - Write-Host "::notice::Provisioning summary classification=$classification profile=$provisioningProfile" + Write-Host "::notice::Editor validation 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 + "``)") + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ("- Editor validation summary: ``" + $summary.FullName + "``") + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ("- Editor validation classification: ``" + $classification + "`` (profile ``" + $provisioningProfile + "``)") } if ($classification -ne 'success' -and $classification -ne '(missing)') { $missingModules = @() @@ -141,10 +141,10 @@ runs: } else { "" } - Write-Host "::error::Provisioning failed before Unity results verification: classification=$classification profile=$provisioningProfile summary=$($summary.FullName)$missingText" + Write-Host "::error::Editor validation 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 "::notice::Editor validation summary metadata could not be parsed: $($_.Exception.Message)" } } } @@ -477,7 +477,7 @@ runs: # 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-EditorValidationDiagnosticsHint -ResultsDir $dir Write-Host "::error::No artifacts directory ($dir) -- Unity Test Runner did not produce results for $label" exit 1 } @@ -490,7 +490,7 @@ runs: # Test-Path block); a second call here would emit duplicate # `::error::` annotations for the same hits. Write-UnityPackageManagerFailureHints -ResultsDir $dir - Write-ProvisioningDiagnosticsHint -ResultsDir $dir + Write-EditorValidationDiagnosticsHint -ResultsDir $dir Write-Host "::error::No NUnit results.xml (with a element) under $dir -- tests did not run for $label" exit 1 } diff --git a/.github/workflows/perf-numbers.yml b/.github/workflows/perf-numbers.yml index 1e40686d..6050e4f4 100644 --- a/.github/workflows/perf-numbers.yml +++ b/.github/workflows/perf-numbers.yml @@ -240,6 +240,32 @@ jobs: -OutputJson (Join-Path $artifactsPath 'machine-specs.json') ` -OutputSummary (Join-Path $artifactsPath 'machine-specs.txt') + # Editors and modules are installed manually by a runner administrator. + # Validate before taking the organization lock. + - name: Validate installed Unity Editor + id: validate_editor + if: ${{ steps.compute.outputs.is-empty != 'true' }} + timeout-minutes: 10 + shell: pwsh + run: | + $artifactsPath = '.artifacts/unity/perf/${{ matrix.unity-version }}-${{ matrix.test-mode }}' + $diagnosticsPath = Join-Path $artifactsPath 'editor-validation' + $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 }}' ` + -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` + -CiManagedOnly ` + -ProvisioningProfile $provisioningProfile ` + -DiagnosticsPath $diagnosticsFile ` + -RequireHealthyExisting + "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Acquire organization Unity lock id: acquire_lock timeout-minutes: 305 @@ -259,45 +285,19 @@ jobs: shell: pwsh run: exit 1 - # The Standalone leg provisions IL2CPP support; PlayMode needs only the - # editor. Both mutate the shared tool-cache root, so installation and - # repair are serialized by the organization lock before licensed work. - - name: Provision Unity Editor - id: provision_editor - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} - timeout-minutes: 180 - shell: pwsh - run: | - $artifactsPath = '.artifacts/unity/perf/${{ 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 }}' ` - -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` - -CiManagedOnly ` - -ProvisioningProfile $provisioningProfile ` - -DiagnosticsPath $diagnosticsFile - "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append - - - name: Upload Unity provisioning diagnostics + - name: Upload Unity editor validation diagnostics if: always() timeout-minutes: 5 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: perf-provisioning-${{ matrix.unity-version }}-${{ matrix.test-mode }} - path: .artifacts/unity/perf/${{ matrix.unity-version }}-${{ matrix.test-mode }}/provisioning + name: perf-editor-validation-${{ matrix.unity-version }}-${{ matrix.test-mode }} + path: .artifacts/unity/perf/${{ matrix.unity-version }}-${{ matrix.test-mode }}/editor-validation if-no-files-found: warn retention-days: 14 - name: Run Unity Test Runner id: run_tests - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' && steps.provision_editor.outcome == 'success' }} + if: ${{ steps.compute.outputs.is-empty != 'true' && steps.validate_editor.outcome == 'success' && steps.acquire_lock.outputs.acquired == 'true' }} # 180 (not 120): the Standalone leg builds a COLD IL2CPP player, which is # far slower than a Mono build. The PlayMode leg runs IN-EDITOR (Mono) with # no player build and is faster, so 180 is generous for it too. Stays diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1a94466..2d41b0eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -237,11 +237,28 @@ jobs: UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - # Skip provisioning, the org lock, and the run when no DxMessaging test - # assembly matched (compute resolved an empty list). The verify step runs - # only after compute succeeds and is told the run was an expected skip via - # expected-empty. Mirrors unity-tests.yml. For the current asmdef set this - # never triggers; it is the robustness path for an empty target. + # Skip editor validation and Unity work when no test assembly matched. + # Lock acquisition remains structurally unconditional for the analyzer; + # the expected-empty signal lets result verification report a typed skip. + - name: Validate installed Unity Editor + id: validate_editor + if: ${{ steps.compute.outputs.is-empty != 'true' }} + timeout-minutes: 10 + shell: pwsh + run: | + $artifactsPath = '.artifacts/unity/release-editmode' + $diagnosticsPath = Join-Path $artifactsPath 'editor-validation' + $diagnosticsFile = Join-Path $diagnosticsPath 'ensure-editor-summary.json' + New-Item -ItemType Directory -Force -Path $diagnosticsPath | Out-Null + $editor = ./scripts/unity/ensure-editor.ps1 ` + -UnityVersion '2022.3.45f1' ` + -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` + -CiManagedOnly ` + -ProvisioningProfile EditorOnly ` + -DiagnosticsPath $diagnosticsFile ` + -RequireHealthyExisting + "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Acquire organization Unity lock id: acquire_lock timeout-minutes: 305 @@ -261,38 +278,19 @@ jobs: shell: pwsh run: exit 1 - # Serialize shared tool-cache installation and repair with licensed work. - - name: Provision Unity Editor - id: provision_editor - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' }} - timeout-minutes: 180 - shell: pwsh - run: | - $artifactsPath = '.artifacts/unity/release-editmode' - $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 '2022.3.45f1' ` - -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` - -CiManagedOnly ` - -ProvisioningProfile EditorOnly ` - -DiagnosticsPath $diagnosticsFile - "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append - - - name: Upload Unity provisioning diagnostics + - name: Upload Unity editor validation diagnostics if: always() timeout-minutes: 5 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: unity-release-provisioning-editmode - path: .artifacts/unity/release-editmode/provisioning + name: unity-release-editor-validation-editmode + path: .artifacts/unity/release-editmode/editor-validation if-no-files-found: warn retention-days: 14 - name: Run Unity Test Runner id: run_tests - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' && steps.provision_editor.outcome == 'success' }} + if: ${{ steps.compute.outputs.is-empty != 'true' && steps.validate_editor.outcome == 'success' && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 120 shell: pwsh env: @@ -330,7 +328,7 @@ jobs: # composite fails unless a results XML exists AND reports total > 0. # Gated with !cancelled() (NOT always()) so a user-initiated cancel # does not race against this step and emit a misleading "tests did not - # run" annotation. Also skips when setup/provisioning/lock work failed + # run" annotation. Also skips when setup/editor-validation/lock work failed # before the Unity run step attempted; that earlier failure remains the # actionable signal. Matches unity-tests.yml and unity-benchmarks.yml. - name: Verify tests actually ran @@ -470,6 +468,24 @@ jobs: UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + - name: Validate installed Unity Editor + id: validate_editor + timeout-minutes: 10 + shell: pwsh + run: | + $artifactsPath = '.artifacts/unity/release-unitypackage' + $diagnosticsPath = Join-Path $artifactsPath 'editor-validation' + $diagnosticsFile = Join-Path $diagnosticsPath 'ensure-editor-summary.json' + New-Item -ItemType Directory -Force -Path $diagnosticsPath | Out-Null + $editor = ./scripts/unity/ensure-editor.ps1 ` + -UnityVersion '2022.3.45f1' ` + -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` + -CiManagedOnly ` + -ProvisioningProfile EditorOnly ` + -DiagnosticsPath $diagnosticsFile ` + -RequireHealthyExisting + "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Acquire organization Unity lock id: acquire_lock timeout-minutes: 305 @@ -489,33 +505,13 @@ jobs: shell: pwsh run: exit 1 - # The exporter needs only EditorOnly, but its shared cache mutation still - # requires organization serialization before licensed work begins. - - name: Provision Unity Editor - id: provision_editor - if: ${{ steps.acquire_lock.outputs.acquired == 'true' }} - timeout-minutes: 180 - shell: pwsh - run: | - $artifactsPath = '.artifacts/unity/release-unitypackage' - $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 '2022.3.45f1' ` - -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` - -CiManagedOnly ` - -ProvisioningProfile EditorOnly ` - -DiagnosticsPath $diagnosticsFile - "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append - - - name: Upload Unity provisioning diagnostics + - name: Upload Unity editor validation diagnostics if: always() timeout-minutes: 5 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: unity-release-provisioning-unitypackage - path: .artifacts/unity/release-unitypackage/provisioning + name: unity-release-editor-validation-unitypackage + path: .artifacts/unity/release-unitypackage/editor-validation if-no-files-found: warn retention-days: 14 # The documented recovery for a failed export is re-running this @@ -525,7 +521,7 @@ jobs: - name: Export the .unitypackage id: export_unitypackage - if: ${{ steps.acquire_lock.outputs.acquired == 'true' && steps.provision_editor.outcome == 'success' }} + if: ${{ steps.validate_editor.outcome == 'success' && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 120 shell: pwsh env: diff --git a/.github/workflows/runner-bootstrap.yml b/.github/workflows/runner-bootstrap.yml index 37e63c7f..0bc4c25a 100644 --- a/.github/workflows/runner-bootstrap.yml +++ b/.github/workflows/runner-bootstrap.yml @@ -1,65 +1,38 @@ -name: Runner Bootstrap (Windows) +name: Runner Audit (Windows) -# 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. +# Operator-driven, workflow_dispatch-only audit of a self-hosted Windows +# runner's host prerequisites, exact Unity editors, and required modules. +# This workflow is intentionally validation-only: GitHub Actions does not have +# the administrator credentials required to install or repair runner software. +# An administrator performs all installation and repair directly on the host, +# then dispatches this audit to confirm the runner is ready. # # 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 (per docs/ops/ci-and-github-settings.md). -# * To bootstrap one specific machine without affecting the other, take +# * To audit 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. +# a silent audit of the WRONG machine would be 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. DXM_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. +# Windows PowerShell 5.1 is used because the audit must still report a missing +# PowerShell 7 prerequisite. on: workflow_dispatch: inputs: runner-label: description: >- - (HARD-FAIL on wrong target) Name of the runner you want to bootstrap + (HARD-FAIL on wrong target) Name of the runner you want to audit (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 + silently audit the unintended machine. Take the unwanted runner offline (Settings -> Runners) BEFORE dispatching to guarantee targeting. required: true @@ -67,27 +40,15 @@ on: 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 +# Forbid concurrent audits 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. +# dispatches (one per machine) cannot overlap. This is stricter than per-runner +# concurrency: any in-flight audit anywhere blocks another audit 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 + group: runner-audit-windows cancel-in-progress: false permissions: @@ -118,7 +79,7 @@ jobs: # 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 }} + name: Audit ${{ inputs.runner-label }} needs: - runner-preflight # Cannot dynamically pin to a specific runner without machine-name labels @@ -141,7 +102,7 @@ jobs: run: git config --global core.longpaths true - name: Checkout - # Shallow checkout: bootstrap only needs scripts/unity/* and a + # Shallow checkout: the audit only needs scripts/unity/* and a # composite action; no history depth required. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 env: @@ -150,9 +111,8 @@ jobs: 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. + # F15: Windows PowerShell 5.1, NOT pwsh, so the audit can report a + # missing PowerShell 7 prerequisite. shell: powershell env: DXM_BOOTSTRAP_REQUESTED_RUNNER: ${{ inputs.runner-label }} @@ -164,15 +124,15 @@ jobs: 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 + # audit the wrong 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 " + + Write-Host ("::error::Runner audit 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: " + + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ("## Runner Audit (FAILED: wrong target)`n`n- Requested runner: ``$requested```n- Actual runner: ``$actual```n- Action: " + "hard-fail; take ``$actual`` offline and re-dispatch.") } exit 1 @@ -180,42 +140,31 @@ jobs: } # Surface to the step summary for the Actions UI. $summary = @( - "## Runner Bootstrap" + "## Runner Audit" "" "- Requested runner: ``$requested``" "- Actual runner: ``$actual``" - "- Detect-only: ``${{ inputs.detect-only }}``" + "- Mode: ``validation-only``" ) -join "`n" if ($env:GITHUB_STEP_SUMMARY) { Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary } - - name: Run runner maintenance (single-step transcript) + - name: Run runner audit (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 + # the audit 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 DXM_RUNNER_DISABLE_AUTO_BOOTSTRAP=1 here (forces - # DetectOnly) so the operator override behaves identically across - # the workflow path and the composite path. + # pwsh is missing, which this workflow must report. This workflow's + # logic stays thin: it owns only the transcript wrapper + # and always passes DetectOnly. All detection logic lives inside + # scripts/unity/maintain-windows-runner.ps1 and + # scripts/unity/bootstrap-windows-runner.ps1. shell: powershell - env: - DXM_BOOTSTRAP_DETECT_ONLY: ${{ inputs.detect-only }} run: | Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' @@ -225,20 +174,7 @@ jobs: $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:DXM_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 -- DXM_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:DXM_RUNNER_DISABLE_AUTO_BOOTSTRAP -eq '1') { - Write-Host "::notice::DXM_RUNNER_DISABLE_AUTO_BOOTSTRAP=1 -> forcing DetectOnly (overrides inputs.detect-only)" - $detectOnly = $true - } - Write-Host "Resolved detect-only: $detectOnly (raw='$rawDetect')" + Write-Host "Resolved mode: validation-only" # F6: drop the GITHUB_ACTION_PATH `../../../` ascent. We are inside # a workflow (not a composite), so $GITHUB_WORKSPACE is reliably @@ -256,16 +192,13 @@ jobs: . $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' } + $installRoot = Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3' $maintenanceArgs = @{ UnityVersions = $unityVersions ProvisioningProfile = $provisioningProfile InstallRoot = $installRoot - Force = $true DiagnosticsRoot = $artifactDir - } - if ($detectOnly) { - $maintenanceArgs.DetectOnly = $true + DetectOnly = $true } $code = Invoke-WindowsRunnerMaintenance @maintenanceArgs } finally { diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml index 49671943..bb429105 100644 --- a/.github/workflows/unity-benchmarks.yml +++ b/.github/workflows/unity-benchmarks.yml @@ -127,12 +127,28 @@ jobs: 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, so an empty discovery never takes the - # paid Unity seat / org lock only to fail late at verify. The verify step - # runs only after compute succeeds and is told via expected-empty. Mirrors - # unity-tests.yml. The benchmark list is structurally non-empty, so this - # is defense-in-depth and uniformity with the required workflows. + # Skip editor validation and Unity work when no assembly matched. Lock + # acquisition remains structurally unconditional for the analyzer. The + # benchmark list is currently non-empty, so this is defense-in-depth. + - name: Validate installed Unity Editor + id: validate_editor + if: ${{ steps.compute.outputs.is-empty != 'true' }} + timeout-minutes: 10 + shell: pwsh + run: | + $artifactsPath = '.artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }}' + $diagnosticsPath = Join-Path $artifactsPath 'editor-validation' + $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 }}' ` + -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` + -CiManagedOnly ` + -ProvisioningProfile EditorOnly ` + -DiagnosticsPath $diagnosticsFile ` + -RequireHealthyExisting + "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Acquire organization Unity lock id: acquire_lock timeout-minutes: 305 @@ -152,38 +168,19 @@ jobs: shell: pwsh run: exit 1 - # Serialize shared tool-cache installation and repair with licensed work. - - name: Provision Unity Editor - id: provision_editor - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == '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 }}' ` - -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` - -CiManagedOnly ` - -ProvisioningProfile EditorOnly ` - -DiagnosticsPath $diagnosticsFile - "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append - - - name: Upload Unity provisioning diagnostics + - name: Upload Unity editor validation diagnostics if: always() timeout-minutes: 5 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: unity-benchmark-provisioning-${{ matrix.unity-version }}-${{ matrix.test-mode }} - path: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }}/provisioning + name: unity-benchmark-editor-validation-${{ matrix.unity-version }}-${{ matrix.test-mode }} + path: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }}/editor-validation if-no-files-found: warn retention-days: 90 - name: Run Unity Test Runner id: run_tests - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' && steps.provision_editor.outcome == 'success' }} + if: ${{ steps.compute.outputs.is-empty != 'true' && steps.validate_editor.outcome == 'success' && steps.acquire_lock.outputs.acquired == 'true' }} timeout-minutes: 120 shell: pwsh env: @@ -229,7 +226,7 @@ jobs: # 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 + # is the explanation. Also skip if setup/editor-validation/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" / diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index a2ca1974..a42d6e08 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -165,14 +165,35 @@ jobs: UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - # Skip provisioning, the org lock, and the run when no DxMessaging 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. + # Skip editor validation and Unity work when no test assembly matched. + # Lock acquisition remains structurally unconditional for the analyzer; + # the expected-empty signal lets result verification report a typed skip. + # Editors and modules are installed manually by a runner administrator. + # CI only verifies the exact trusted tool-cache install and never repairs it. + - name: Validate installed Unity Editor + id: validate_editor + if: ${{ steps.compute.outputs.is-empty != 'true' }} + timeout-minutes: 10 + shell: pwsh + run: | + $artifactsPath = '.artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}' + $diagnosticsPath = Join-Path $artifactsPath 'editor-validation' + $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 }}' ` + -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` + -CiManagedOnly ` + -ProvisioningProfile $provisioningProfile ` + -DiagnosticsPath $diagnosticsFile ` + -RequireHealthyExisting + "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Require current PR head before lock acquisition if: ${{ steps.compute.outputs.is-empty != 'true' }} timeout-minutes: 2 @@ -204,45 +225,19 @@ jobs: shell: pwsh run: exit 1 - # The shared tool-cache editor root is organization-wide runner state. - # Provision only while holding the same lock that serializes licensed - # work, so concurrent repositories cannot repair or replace one editor. - - name: Provision Unity Editor - id: provision_editor - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == '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 }}' ` - -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') ` - -CiManagedOnly ` - -ProvisioningProfile $provisioningProfile ` - -DiagnosticsPath $diagnosticsFile - "UNITY_EDITOR_PATH=$editor" | Out-File -FilePath $env:GITHUB_ENV -Append - - - name: Upload Unity provisioning diagnostics + - name: Upload Unity editor validation diagnostics if: always() timeout-minutes: 5 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: unity-provisioning-${{ matrix.unity-version }}-${{ matrix.test-mode }} - path: .artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}/provisioning + name: unity-editor-validation-${{ matrix.unity-version }}-${{ matrix.test-mode }} + path: .artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}/editor-validation if-no-files-found: warn retention-days: 14 - name: Run Unity Test Runner id: run_tests - if: ${{ steps.compute.outputs.is-empty != 'true' && steps.acquire_lock.outputs.acquired == 'true' && steps.provision_editor.outcome == 'success' }} + if: ${{ steps.compute.outputs.is-empty != 'true' && steps.validate_editor.outcome == 'success' && steps.acquire_lock.outputs.acquired == 'true' }} # 150 (was 120): the standalone leg builds a NON-development IL2CPP # player with the Release C++ configuration. Measured PR runs showed the # Debug C++ configuration reduces native compile time but makes the @@ -292,7 +287,7 @@ jobs: # 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 + # is the explanation. Also skip if setup/editor-validation/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" / diff --git a/.llm/context.md b/.llm/context.md index a0b47ab1..38c7e089 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -104,7 +104,7 @@ editor), NOT inside the devcontainer. The container ships no local Unity build. - `actions/create-github-app-token@v3` requires `app-id` + `private-key`; do not use legacy `client-id` in workflows. - Never use a single shared `concurrency.group` across multiple matrix entries without mitigation (expand the group with a `${{ matrix.* }}` token, declare `queue: max` with `cancel-in-progress: false`, or set `strategy.max-parallel: 1`). - Do not use native GitHub `concurrency.group: wallstop-organization-builds`; the organization lock name belongs only in the central `Ambiguous-Interactive/ambiguous-organization-build-lock` acquire/release action inputs. -- Unity is activated with a classic serial (`UNITY_SERIAL` + `UNITY_EMAIL` + `UNITY_PASSWORD`); the floating licensing server is RETIRED (`UNITY_LICENSING_SERVER` removed). Every Unity-credential-using job must validate the serial secrets, provision the editor (`scripts/unity/ensure-editor.ps1 -CiManagedOnly` with an explicit `-ProvisioningProfile`) BEFORE the org lock, acquire `wallstop-organization-builds` immediately before `scripts/unity/run-ci-tests.ps1`, and release it with `if: always()`. The license is returned on every exit path (defensive return-at-start, `finally` return, `if: always()` `return-unity-license` step). See [Unity License Return Guarantee](./skills/unity-licensing/references/unity-license-return-guarantee.md) and [Unity CI Matrix](./skills/unity-editor-ci/references/unity-ci-matrix.md). +- Unity is activated with a classic serial (`UNITY_SERIAL` + `UNITY_EMAIL` + `UNITY_PASSWORD`); the floating licensing server is RETIRED (`UNITY_LICENSING_SERVER` removed). Editors and modules are installed manually by a runner administrator under `RUNNER_TOOL_CACHE/u6-v3`; workflows MUST NOT install, repair, uninstall, or quarantine editors. Every Unity-credential-using job validates the exact editor with `scripts/unity/ensure-editor.ps1 -CiManagedOnly -RequireHealthyExisting` and an explicit `-ProvisioningProfile` BEFORE the org lock, acquires `wallstop-organization-builds` immediately before licensed work, and releases it with `if: always()`. The license is returned on every exit path (defensive return-at-start, `finally` return, `if: always()` `return-unity-license` step). See [Unity License Return Guarantee](./skills/unity-licensing/references/unity-license-return-guarantee.md) and [Unity CI Matrix](./skills/unity-editor-ci/references/unity-ci-matrix.md). - Per-runner Unity-cache safety comes from each runner agent's exclusive workspace. CI caches the generated project's `Library` under `.artifacts/unity/projects/-/Library` and Unity package caches under `.artifacts/unity/cache/`; do not add broad restore keys for Unity `Library`. - Unity diagnostic scanners must inspect every `*.log` under the results directory, with `unity.log` first but retry logs such as `unity.first-attempt.log` included. UPM retry failures often keep the actionable cancellation signal only in the preserved first-attempt log. - Unity versions are single-sourced in `.github/unity-versions.json` (`all` = full CI set, `latest` = last `all` entry, `release` = pinned release version); bump ONLY that file. `npm run validate:unity-versions` enforces zero drift. See [Unity Version Single Source of Truth](./skills/unity-editor-ci/references/unity-version-single-source.md). diff --git a/.llm/index.json b/.llm/index.json index a08894a4..2ca3222c 100644 --- a/.llm/index.json +++ b/.llm/index.json @@ -426,12 +426,12 @@ { "name": "unity-editor-ci", "path": ".llm/skills/unity-editor-ci/SKILL.md", - "description": "Unity CI on self-hosted Windows runners: the unity-tests.yml matrix of 3 Unity versions x {editmode, playmode, standalone}, the canonical version list in .github/unity-versions.json enforced by npm run validate:unity-versions, the organization build-lock and timeout invariants that protect the two-seat Unity serial, the ensure-editor.ps1 standalone-CLI bootstrap with its PATH refresh and module quarantine/reinstall repair, Windows host prerequisites for 0xC0000135 startup failures, and repo-wide GitHub Action version pins. Use when bumping a Unity version, adding a matrix cell, triaging an IL2CPP-only or license or editor-provisioning failure, or editing a Unity workflow.", + "description": "Unity CI on self-hosted Windows runners: the unity-tests.yml matrix of 3 Unity versions x {editmode, playmode, standalone}, manual administrator installation under RUNNER_TOOL_CACHE/u6-v3, validation-only workflow checks with ensure-editor.ps1 -RequireHealthyExisting, the organization build-lock and timeout invariants that protect the two-seat Unity serial, Windows host prerequisites for 0xC0000135 startup failures, and repo-wide GitHub Action version pins. Use when bumping a Unity version, adding a matrix cell, triaging an IL2CPP-only, license, or editor-validation failure, or editing a Unity workflow.", "metadata": { "category": "unity", "tags": "unity, ci, matrix, il2cpp, lts, game-ci" }, - "lineCount": 125, + "lineCount": 126, "references": [ ".llm/skills/unity-editor-ci/references/github-actions-version-consistency.md", ".llm/skills/unity-editor-ci/references/unity-ci-matrix.md", diff --git a/.llm/index.md b/.llm/index.md index b8aa3de3..21b8ef8e 100644 --- a/.llm/index.md +++ b/.llm/index.md @@ -31,7 +31,7 @@ directory: `SKILL.md` holds the instructions, `references/` holds supporting det | [test-diagnostics](./skills/test-diagnostics/SKILL.md) | 3 | Making an opaque test failure explain itself: diagnostic collector classes that record an execution trace and render a BuildReport on failure, an EditorDiagnostics static with an Enabled flag plus NameFilter for zero-overhead-when-off logging, enabling and restoring diagnostics per fixture in SetUp/TearDown, and UNH-SUPPRESS markers for intentional edge cases. Use when an assertion only reports expected-vs-actual counts, when debugging spatial queries, state machines, or other multi-step algorithms, or when adding temporary logging to a test. | | [test-failure-investigation](./skills/test-failure-investigation/SKILL.md) | 3 | The zero-flaky policy and the eight-step procedure for a failing or intermittent test: reproduce it in a loop, state the expected behavior, capture actual state, read the production code path, categorize the root cause (production bug, test bug, setup bug, order dependency, timing, environment), fix the cause, re-run repeatedly, and document the finding. Includes the banned fixes - Thread.Sleep, WaitForSeconds, [Ignore], retry loops, swallowed try/catch, raised timeouts, deleting the test. Use when a test fails, is flaky, passes locally but fails in CI, or passes alone but fails with the suite. | | [test-fixtures-and-cleanup](./skills/test-fixtures-and-cleanup/SKILL.md) | 6 | Test fixture lifecycle for Unity tests: a CommonTestBase that tracks GameObjects, disposables, and scenes for automatic destruction; the Track/TrackDisposable/CreateGameObject/CreateTestScene helpers; DeferAssetCleanupToOneTimeTearDown for expensive shared assets; and reference-counted static fixtures with paired Acquire/Release in OneTimeSetUp/OneTimeTearDown. Use when a test leaks GameObjects or textures, when tests pollute each other, when a fixture is expensive enough to share across test classes, or when writing a new test base class. | -| [unity-editor-ci](./skills/unity-editor-ci/SKILL.md) | 5 | Unity CI on self-hosted Windows runners: the unity-tests.yml matrix of 3 Unity versions x {editmode, playmode, standalone}, the canonical version list in .github/unity-versions.json enforced by npm run validate:unity-versions, the organization build-lock and timeout invariants that protect the two-seat Unity serial, the ensure-editor.ps1 standalone-CLI bootstrap with its PATH refresh and module quarantine/reinstall repair, Windows host prerequisites for 0xC0000135 startup failures, and repo-wide GitHub Action version pins. Use when bumping a Unity version, adding a matrix cell, triaging an IL2CPP-only or license or editor-provisioning failure, or editing a Unity workflow. | +| [unity-editor-ci](./skills/unity-editor-ci/SKILL.md) | 5 | Unity CI on self-hosted Windows runners: the unity-tests.yml matrix of 3 Unity versions x {editmode, playmode, standalone}, manual administrator installation under RUNNER_TOOL_CACHE/u6-v3, validation-only workflow checks with ensure-editor.ps1 -RequireHealthyExisting, the organization build-lock and timeout invariants that protect the two-seat Unity serial, Windows host prerequisites for 0xC0000135 startup failures, and repo-wide GitHub Action version pins. Use when bumping a Unity version, adding a matrix cell, triaging an IL2CPP-only, license, or editor-validation failure, or editing a Unity workflow. | | [unity-editor-conventions](./skills/unity-editor-conventions/SKILL.md) | 3 | Three Unity-side contracts for DxMessaging: the MessageAwareComponent base-call contract (Awake, OnEnable, OnDisable, OnDestroy, RegisterMessageHandlers must chain base.(), enforced by DXMSG006-DXMSG010 plus an IL scanner, inspector overlay, runtime breadcrumb, and meta-test); the package-owned editor design system built on DxMessagingEditorTheme, UI Toolkit, and EditorWindowTestUtility; and the devcontainer named-volume cache contract in .devcontainer/cache-contract.sh. Use when subclassing MessageAwareComponent, adding a guarded lifecycle method, styling or testing an editor window or inspector, or adding/diagnosing a devcontainer cache mount. | | [unity-licensing](./skills/unity-licensing/SKILL.md) | 2 | Unity Editor licensing for CI: classic serial activation with the UNITY_SERIAL, UNITY_EMAIL, and UNITY_PASSWORD secrets, the four-layer always-return guarantee (return-at-start, PowerShell try/finally, an if: always() return-unity-license workflow step inside the org-lock window, and the next run's return-at-start), the seven-step per-job flow, the roughly two-seat no-reclaim tradeoff, and the retired UNITY_LICENSING_SERVER secret that must not come back. Use when a Unity job reports Failed to activate, No valid Unity Editor license found, or consumed serial seats; when wiring Unity secrets on a runner; or when editing license handling in run-ci-tests.ps1 or a Unity workflow. Local Unity needs no license. | | [unity-mcp-test-loop](./skills/unity-mcp-test-loop/SKILL.md) | 1 | Running Unity EditMode and PlayMode tests locally from the Linux devcontainer against the Windows host editor over the unity-mcp MCP server: the scripts/mcp/unity-mcp.mjs entry point and its npm run unity:mcp:bridge / :probe / :configure commands, endpoint discovery and bearer-token auth, the DxMcpTestRunner.Run bridge with its JSON result and .status sidecar, Unity_RunCommand sandbox restrictions, and using durationSeconds to measure suite speed. Use when running Unity tests locally, when the MCP endpoint is unreachable or unauthorized, when the DxMcpTestRunner bridge is missing, or when capturing a local perf baseline. | diff --git a/.llm/skills/unity-editor-ci/SKILL.md b/.llm/skills/unity-editor-ci/SKILL.md index 006ddb30..9b86f650 100644 --- a/.llm/skills/unity-editor-ci/SKILL.md +++ b/.llm/skills/unity-editor-ci/SKILL.md @@ -1,6 +1,6 @@ --- name: unity-editor-ci -description: "Unity CI on self-hosted Windows runners: the unity-tests.yml matrix of 3 Unity versions x {editmode, playmode, standalone}, the canonical version list in .github/unity-versions.json enforced by npm run validate:unity-versions, the organization build-lock and timeout invariants that protect the two-seat Unity serial, the ensure-editor.ps1 standalone-CLI bootstrap with its PATH refresh and module quarantine/reinstall repair, Windows host prerequisites for 0xC0000135 startup failures, and repo-wide GitHub Action version pins. Use when bumping a Unity version, adding a matrix cell, triaging an IL2CPP-only or license or editor-provisioning failure, or editing a Unity workflow." +description: "Unity CI on self-hosted Windows runners: the unity-tests.yml matrix of 3 Unity versions x {editmode, playmode, standalone}, manual administrator installation under RUNNER_TOOL_CACHE/u6-v3, validation-only workflow checks with ensure-editor.ps1 -RequireHealthyExisting, the organization build-lock and timeout invariants that protect the two-seat Unity serial, Windows host prerequisites for 0xC0000135 startup failures, and repo-wide GitHub Action version pins. Use when bumping a Unity version, adding a matrix cell, triaging an IL2CPP-only, license, or editor-validation failure, or editing a Unity workflow." metadata: category: "unity" tags: "unity, ci, matrix, il2cpp, lts, game-ci" @@ -50,17 +50,17 @@ Windows runners. `unity-tests.yml` is one unified matrix of three Unity versions `unity-benchmarks.yml`, and `perf-numbers.yml`). A native `concurrency.group: wallstop-organization-builds` is repository-scoped and is FORBIDDEN. - Timeout invariant: every step before and including the cleanup gate has an explicit positive - timeout. The acquire step cap (`305`) exceeds its internal wait (`300`), provisioning is - capped at `180`, licensed work is capped at `120` to `180`, and cleanup uses `5`/`2`/`5`/`2` + timeout. Editor validation is capped at `10`, the acquire step cap (`305`) exceeds its + internal wait (`300`), licensed work is capped at `120` to `180`, and cleanup uses `5`/`2`/`5`/`2` for return/classify/release/gate. The `900`-minute job cap must retain at least 60 minutes beyond the sum of those enforced step caps. -- Acquire the lock before editor provisioning and release it with `if: always()`. Provisioning - mutates the shared tool-cache root, so it belongs inside the same lock window as licensed - work. +- Editors and modules are installed manually by a runner administrator under + `RUNNER_TOOL_CACHE/u6-v3`. Workflows MUST NOT install, repair, uninstall, or quarantine + editors. Validate with `-CiManagedOnly -RequireHealthyExisting` before acquiring the lock. ### The compute-unity-assemblies is-empty gate -- The compute step carries `id: compute`. Provisioning and the Unity work step +- The compute step carries `id: compute`. Editor validation and the Unity work step may skip an empty assembly selection, but lock acquisition remains unconditional because each static matrix is structurally non-empty and the analyzer must be able to prove every acquisition. @@ -68,11 +68,12 @@ Windows runners. `unity-tests.yml` is one unified matrix of three Unity versions `is-empty == 'true'` or a non-skipped Unity run step, and receives `expected-empty: ${{ steps.compute.outputs.is-empty }}`. Never gate verify on is-empty alone. -### Editor provisioning (`scripts/unity/ensure-editor.ps1`) +### Editor validation and manual maintenance (`scripts/unity/ensure-editor.ps1`) -- CI must pass `-ProvisioningProfile` explicitly: `EditorOnly` for editmode, playmode, - benchmarks, and release checks; `StandaloneWindowsIl2Cpp` for standalone (installs only - `windows-il2cpp`); `Android` and `Full` for the heavy module sets. +- CI must pass `-CiManagedOnly -RequireHealthyExisting` plus `-ProvisioningProfile` + explicitly: `EditorOnly` for editmode, playmode, benchmarks, and release checks; + `StandaloneWindowsIl2Cpp` for standalone (verifies `windows-il2cpp`); `Android` and `Full` + remain manual-maintenance profiles. - `unity install-path` with NO arguments is a GETTER. The SET form uses a flag (`-s`, then `--set` as fallback) and is best-effort only; discovery always relies on the getter. - The installer only writes the User-scope registry PATH, so the session PATH must be @@ -95,11 +96,11 @@ Windows runners. `unity-tests.yml` is one unified matrix of three Unity versions - Unity 2021.3, 2022.3, and 6000.x need BOTH the VC++ 2010 SP1 x64 Redistributable (`MSVCP100.dll`, `MSVCR100.dll`) and the VC++ 2015-2022 x64 Redistributable (`VCRUNTIME140.dll`, `VCRUNTIME140_1.dll`, `MSVCP140.dll`). They are separate packages. -- `scripts/unity/bootstrap-windows-runner.ps1` installs both, enables +- A runner administrator runs `scripts/unity/bootstrap-windows-runner.ps1` directly on the + host to install both and enable `HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem!LongPathsEnabled = 1`, adds Defender - exclusions, and installs `pwsh` via winget. `.github/actions/assert-unity-host-prereqs` runs - it per job and exports `DXM_RUNNER_PREREQ_INSTALLED=1`; `runner-bootstrap.yml` is the - Actions-only recovery path. `DXM_RUNNER_DISABLE_AUTO_BOOTSTRAP=1` forces `-DetectOnly`. + exclusions, and install `pwsh` via winget. `.github/actions/assert-unity-host-prereqs` and + `runner-bootstrap.yml` always use `-DetectOnly`; workflows never install host prerequisites. ### Caches, logs, and action versions diff --git a/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md b/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md index 2f943ebc..79e4261d 100644 --- a/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md +++ b/.llm/skills/unity-editor-ci/references/unity-ci-matrix.md @@ -67,13 +67,13 @@ With both controls, a run consumes at most one seat while another repository can job timeout-minutes >= sum(all capped steps through cleanup gate) + 60 ``` -The acquire input `timeout-minutes: "300"` is the internal lock-poll budget. Its enclosing step has `timeout-minutes: 305`, so the action can finish and report a timeout before GitHub terminates the step. Editor provisioning is capped at 180 minutes, licensed work at 120 to 180 minutes, and return/classify/release/gate at 5/2/5/2 minutes. The licensed jobs use `timeout-minutes: 900`. `scripts/validate-unity-pr-policy.py` sums every enforced step cap through the cleanup gate and requires at least 60 minutes of remaining job time. +Editor validation is capped at 10 minutes. The acquire input `timeout-minutes: "300"` is the internal lock-poll budget. Its enclosing step has `timeout-minutes: 305`, so the action can finish and report a timeout before GitHub terminates the step. Licensed work is capped at 120 to 180 minutes, and return/classify/release/gate at 5/2/5/2 minutes. The licensed jobs use `timeout-minutes: 900`. `scripts/validate-unity-pr-policy.py` sums every enforced step cap through the cleanup gate and requires at least 60 minutes of remaining job time. The step-level caps protect the in-use seat from a hung editor or ancillary action. They must remain strictly below the job timeout so the step fails first and the cleanup chain still runs. This matters because `stuck-job-watchdog.yml` ignores any `in_progress` job; without step caps, a wedged action can squat the seat until the whole job is cancelled. -Acquire the organization lock before provisioning. All licensed workflows install or repair editors under the shared `RUNNER_TOOL_CACHE/u6-v3` root, so provisioning must be serialized with licensed work. Release the lock only after the always-run return and cleanup-classification steps. +Runner administrators manually install every exact editor and required module under `RUNNER_TOOL_CACHE/u6-v3`. Workflows validate that root with `-CiManagedOnly -RequireHealthyExisting` before acquiring the organization lock; they never install or repair editors. Release the lock only after the always-run return and cleanup-classification steps. -**Operator note (standalone IL2CPP):** the `standalone` cells require the Windows IL2CPP Unity module and the host build toolchain needed by Unity for Windows players. `scripts/unity/ensure-editor.ps1` must be called with an explicit provisioning profile: `EditorOnly` for editmode/playmode/benchmarks/release checks, and `StandaloneWindowsIl2Cpp` for standalone so only `windows-il2cpp` is installed or verified. That script treats the beta standalone Unity CLI as a moving surface: it discovers the install root through the 0-arg `unity install-path` GETTER and sets the path best-effort, so an uncertain flag never aborts the matrix. See [unity-editor-cli-bootstrap](./unity-editor-cli-bootstrap.md) for the getter-vs-setter detail. +**Operator note (standalone IL2CPP):** the `standalone` cells require the Windows IL2CPP Unity module and the host build toolchain needed by Unity for Windows players. `scripts/unity/ensure-editor.ps1` must be called with an explicit provisioning profile: `EditorOnly` for editmode/playmode/benchmarks/release checks, and `StandaloneWindowsIl2Cpp` for standalone so `windows-il2cpp` is verified. See [unity-editor-cli-bootstrap](./unity-editor-cli-bootstrap.md) for manual maintenance details. `unity-benchmarks.yml` (active; manual-only, NEVER on PRs): @@ -86,9 +86,9 @@ The active `unity-benchmarks.yml` explicitly omits `pull_request` and `push` per ## compute-unity-assemblies is-empty Gate -Every workflow that consumes `./.github/actions/compute-unity-assemblies` mirrors the canonical wiring in `unity-tests.yml`: the compute step carries `id: compute`, and provisioning plus Unity work skip when `is-empty == 'true'`. Lock acquisition remains unconditional because the static matrix is structurally non-empty and the organization analyzer must prove each acquisition. When asmdef discovery resolves no owned assemblies, verification treats the empty selection as an intentional skip while the terminal return/classify/release/gate chain still proves cleanup. +Every workflow that consumes `./.github/actions/compute-unity-assemblies` mirrors the canonical wiring in `unity-tests.yml`: the compute step carries `id: compute`, and editor validation plus Unity work skip when `is-empty == 'true'`. Lock acquisition remains unconditional because the static matrix is structurally non-empty and the organization analyzer must prove each acquisition. When asmdef discovery resolves no owned assemblies, verification treats the empty selection as an intentional skip while the terminal return/classify/release/gate chain still proves cleanup. -The `Verify tests actually ran` step keeps a cancellation-safe gate and must also require `steps.compute.outcome == 'success'` plus either `steps.compute.outputs.is-empty == 'true'` or a non-skipped Unity run step (never an is-empty gate alone). It receives `expected-empty: ${{ steps.compute.outputs.is-empty }}`, so an intentional skip reads as success rather than a "tests did not run" failure, while checkout/cache/setup/provisioning/lock failures that prevent Unity from launching are not obscured by a generic missing-results annotation. The skip path does not fire for the current asmdef set; it is the robustness path for a target whose assemblies are all filtered out, such as a runtime-only standalone run when every DxMessaging test asmdef is editor-only. +The `Verify tests actually ran` step keeps a cancellation-safe gate and must also require `steps.compute.outcome == 'success'` plus either `steps.compute.outputs.is-empty == 'true'` or a non-skipped Unity run step (never an is-empty gate alone). It receives `expected-empty: ${{ steps.compute.outputs.is-empty }}`, so an intentional skip reads as success rather than a "tests did not run" failure, while checkout/cache/setup/editor-validation/lock failures that prevent Unity from launching are not obscured by a generic missing-results annotation. The skip path does not fire for the current asmdef set; it is the robustness path for a target whose assemblies are all filtered out, such as a runtime-only standalone run when every DxMessaging test asmdef is editor-only. When editing these workflows, keep every compute step carrying an `id` and every license-consuming step gated on `steps..outputs.is-empty != 'true'`. Do not gate verify on is-empty, and do not remove the gated steps; mirror `unity-tests.yml`. @@ -109,7 +109,9 @@ Add a version to the canonical `.github/unity-versions.json` `all` array when on `2024.3.10f1`). See [Unity Version Single Source of Truth](./unity-version-single-source.md). -1. Verify the Unity standalone CLI can install the requested version on the self-hosted Windows runner, or that the version already exists under `UNITY_EDITOR_INSTALL_ROOT` / `C:\Unity\Editors` / Unity Hub's install path. +1. Have a runner administrator install the exact editor and required modules + under that runner's `RUNNER_TOOL_CACHE/u6-v3//Editor/Unity.exe` + path, then run the validation-only runner audit. 1. Validate the new version locally via the MCP loop against the host editor. The host editor must be running that exact Unity version; then run the EditMode diff --git a/.llm/skills/unity-editor-ci/references/unity-editor-cli-bootstrap.md b/.llm/skills/unity-editor-ci/references/unity-editor-cli-bootstrap.md index 63696659..aa2b2ac9 100644 --- a/.llm/skills/unity-editor-ci/references/unity-editor-cli-bootstrap.md +++ b/.llm/skills/unity-editor-ci/references/unity-editor-cli-bootstrap.md @@ -2,13 +2,13 @@ # Unity Editor CLI Bootstrap -> **One-line summary**: `scripts/unity/ensure-editor.ps1` installs the standalone Unity CLI on a self-hosted Windows runner, refreshes the session `$env:PATH`, discovers the editor through layered CLI-aware resolution, and enforces a profile-scoped Unity editor desired state with quarantine/reinstall repair. +> **One-line summary**: Runner administrators use `scripts/unity/ensure-editor.ps1` for manual editor maintenance; active workflows call it with `-RequireHealthyExisting` for validation only. ## Overview -The active Unity workflows run directly on self-hosted Windows runners and use the standalone Unity CLI (`unity`) to install editors and modules on demand. `scripts/unity/ensure-editor.ps1` is the bootstrap: if `unity` is not already on PATH it downloads and runs the official installer, then provisions the editor according to `-ProvisioningProfile`. CI must pass the profile explicitly. `EditorOnly` installs/verifies only the editor, `StandaloneWindowsIl2Cpp` adds only `windows-il2cpp`, `Android` adds Android player support plus SDK/NDK/OpenJDK disk proof, and `Full` preserves the historical broad module set for manual compatibility. The heavy/flaky Android SDK/NDK payload is scoped to `Android` or `Full`, because the NDK unpack can fail (~93%, exit 6) on Windows runners without long-path support. OpenJDK is NOT in any requested `-m` list -- it arrives as a dependency of `android-sdk-ndk-tools` and is only verified on disk afterward (see [CI module desired state and repair](#ci-module-desired-state-and-repair)). +The active Unity workflows run directly on self-hosted Windows runners, but they do not install or repair editors. A runner administrator installs every exact editor and required module under `RUNNER_TOOL_CACHE/u6-v3`. Workflows call `scripts/unity/ensure-editor.ps1` with `-CiManagedOnly -RequireHealthyExisting` and an explicit `-ProvisioningProfile`; this path performs discovery, module disk checks, and a native startup probe, then fails with manual remediation instructions if anything is missing or unhealthy. The script's standalone Unity CLI install and quarantine/repair paths remain available only for an administrator running maintenance directly on the host. -Editor provisioning runs before the organization Unity license lock. The locked section should only activate/return the serial license and run tests; editor install/repair can take tens of minutes and must not block other licensed Unity jobs. +Editor validation runs before the organization Unity license lock. The locked section only activates/returns the serial license and runs tests. The standalone CLI is a moving beta surface (`v0.1.0-beta.x`). Some flags are undocumented and differ between releases, so the script treats every optional operation as best-effort and never lets an uncertain flag abort the install. Two failure modes drove the current design: the installer leaves `unity` off the current session PATH, and `unity install-path` was once called with a positional directory argument, which the CLI rejected. @@ -122,11 +122,11 @@ The bootstrap keeps two DELIBERATELY DECOUPLED lists, because "what we ASK the C For a missing selected `core` group, repair is enabled by default. The script first tries `unity uninstall ` as a cleanup hint. It then quarantines any remaining managed version directory to `\_quarantine\--` and runs a fresh `unity install ` with the selected profile's requested module ids (the same single-source-of-truth arg vector as the primary install). Repair install retries once when the CLI still reports "already installed" without a resolvable managed editor, clearing stale CLI metadata with another uninstall before retrying. Repair is deliberately bounded to the configured install root; the script refuses to move arbitrary `ProgramFiles` installs. In CI-managed mode, repair resolution also keeps `-ManagedOnly` so a host install cannot be selected after reinstall. The same uninstall-plus-version-directory quarantine path handles partial installs where the CLI reports "already installed" but no `Unity.exe` leaf exists, so stale CLI metadata cannot keep returning the same no-op state. -Set `DXM_UNITY_DISABLE_EDITOR_REPAIR=1` only when debugging the installer itself. Normal CI should keep repair enabled so manually copied, partial, or non-Hub/non-CLI-managed editors converge to a known-good CLI-managed install. +Set `DXM_UNITY_DISABLE_EDITOR_REPAIR=1` only when debugging a manual maintenance run. CI always uses `-RequireHealthyExisting`, which exits before every install or repair path. -After module validation, `ensure-editor.ps1` runs a native startup probe before the license lock. If startup fails, the script performs one managed reinstall AND re-runs `Ensure-UnityCiModules` with the selected profile, then probes again. A second startup failure is classified as host OS/runtime prerequisite damage (for example missing native DLLs such as `0xC0000135` / `STATUS_DLL_NOT_FOUND`), not a package/test failure. +After module validation, `ensure-editor.ps1` runs a native startup probe before the license lock. Under `-RequireHealthyExisting`, any startup failure stops immediately and requires administrator remediation. -The `0xC0000135` failure mode has its own short-circuit: both probe sites emit a wrap-immune single-line `::error::` annotation BEFORE the throw and refuse to loop on a futile Unity reinstall (the missing DLL is on the OS, not in the Unity install). The host-OS prereqs themselves are remediated out-of-band by [Unity Runner Host Prerequisites](./unity-runner-host-prereqs.md) -- a per-job preflight composite (`.github/actions/assert-unity-host-prereqs`) auto-installs the Microsoft Visual C++ 2015-2022 Redistributable, Windows long-path support, Defender exclusions, and PowerShell 7, exporting `DXM_RUNNER_PREREQ_INSTALLED=1` on success so the short-circuit annotation can adjust the suggested cause when the preflight has already run. +The `0xC0000135` failure mode emits a wrap-immune single-line `::error::` annotation before the throw. Host prerequisites are installed manually by an administrator and checked by [Unity Runner Host Prerequisites](./unity-runner-host-prereqs.md). ## Verification (invariants to keep honest in review) @@ -134,7 +134,7 @@ The `0xC0000135` failure mode has its own short-circuit: both probe sites emit a - The PATH fix stays intact: `$script:UnityCliPath`, `GetEnvironmentVariable`, `Update-SessionPathFromRegistry`, and the absolute-path fallback; `install-path` SET uses `-s` (never a positional `$Root`); `Invoke-UnityCliSafe` exists; `Get-UnityCliInstallRoot` reads `@('install-path')`; discovery stays defensive (`--format json` / `ConvertFrom-Json`); quarantine/reinstall repair is preserved. - The `--accept-eula` contract: `Get-UnityCliModuleInstallArguments` remains the ONLY place that builds a module-install (`install`/`install-modules` ... `-m` ...) arg vector, every live install call site routes through it, and the requested `-m` ids exclude `android-open-jdk` while the verified groups include it. - Disk-proof idempotency, managed-install quarantine/reinstall, and the `DXM_UNITY_DISABLE_EDITOR_REPAIR=1` refusal path keep working. -- Active Unity workflows provision editors before acquiring the organization Unity lock and export `UNITY_EDITOR_PATH` for the locked test step. +- Active Unity workflows validate `RUNNER_TOOL_CACHE/u6-v3` with `-CiManagedOnly -RequireHealthyExisting` before acquiring the organization Unity lock and export `UNITY_EDITOR_PATH` for the locked test step. ## See Also diff --git a/.llm/skills/unity-editor-ci/references/unity-runner-host-prereqs.md b/.llm/skills/unity-editor-ci/references/unity-runner-host-prereqs.md index d5a383c9..f6b05eba 100644 --- a/.llm/skills/unity-editor-ci/references/unity-runner-host-prereqs.md +++ b/.llm/skills/unity-editor-ci/references/unity-runner-host-prereqs.md @@ -5,7 +5,7 @@ > **One-line summary**: Use a four-layer defense for self-hosted Windows > Unity runners: bootstrap host prerequisites (BOTH VC++ 2010 and 2015-2022 > generations), preflight every Unity job, short-circuit host-level startup -> faults, and keep an Actions recovery path. +> faults, and keep an Actions audit path. ## Overview @@ -27,12 +27,12 @@ on downlevel Windows. It is idempotent and supports `-DetectOnly`. Three entry points consume it: -1. Operators run it locally on the runner host when they have access. +1. A runner administrator runs it locally and elevated on the host for installation or repair. 1. `.github/workflows/runner-bootstrap.yml` exposes a `workflow_dispatch` - recovery path for operators who only have Actions access. It hard-fails - wrong-target dispatch instead of bootstrapping an unintended runner. + validation-only audit. It hard-fails wrong-target dispatch. 1. `.github/actions/assert-unity-host-prereqs/` runs at the start of every - Unity job. On success it exports `DXM_RUNNER_PREREQ_INSTALLED=1`. + Unity job with `-DetectOnly`. On success it exports + `DXM_RUNNER_PREREQ_INSTALLED=1`. `scripts/unity/ensure-editor.ps1` keeps the repair boundary honest. If the native Unity startup probe returns `0xC0000135 / STATUS_DLL_NOT_FOUND`, it @@ -42,8 +42,8 @@ quarantine/reinstall. Missing loader DLLs are host damage, not editor damage. ## Problem Statement Freshly imaged self-hosted Windows runners can miss DLLs that GitHub-hosted -Windows runners already include. Unity then fails during `Provision Unity -Editor` with `-1073741515 (0xC0000135 / STATUS_DLL_NOT_FOUND)`. The two +Windows runners already include. Unity then fails during `Validate installed +Unity Editor` with `-1073741515 (0xC0000135 / STATUS_DLL_NOT_FOUND)`. The two common cases are: - Missing `MSVCP100.dll` / `MSVCR100.dll` (VC++ 2010 SP1 generation -- the @@ -57,8 +57,8 @@ does NOT install the other. The bootstrap installs both in one pass. Before this defense, `ensure-editor.ps1` treated that startup failure as a Unity-install fault. It quarantined and reinstalled the editor, failed the same startup probe again, and made each matrix cell pay minutes of unrecoverable -work. The fix belongs at the host-OS prereq layer and must be reachable without -manual host access whenever possible. +work. The fix belongs at the host-OS prereq layer and requires administrator +access to the runner host. ## Solution @@ -66,21 +66,23 @@ Keep four layers in sync: 1. **One-shot installer**: `scripts/unity/bootstrap-windows-runner.ps1` detects every host prereq, - repairs missing supported prereqs by default, and supports `-DetectOnly`. + repairs missing supported prereqs when run manually by an administrator, + and supports `-DetectOnly`. 1. **Per-job preflight**: `.github/actions/assert-unity-host-prereqs/action.yml` invokes the same - script before Unity work and exports `DXM_RUNNER_PREREQ_INSTALLED=1`. + script with `-DetectOnly` before Unity work and exports + `DXM_RUNNER_PREREQ_INSTALLED=1`. 1. **Startup short-circuit**: `scripts/unity/ensure-editor.ps1` recognizes `0xC0000135`, prints context-aware guidance, and refuses futile Unity reinstall retries. -1. **Operator auto-recovery**: - `.github/workflows/runner-bootstrap.yml` lets an Actions operator run the - bootstrap from the UI when direct runner access is unavailable. +1. **Operator audit**: + `.github/workflows/runner-bootstrap.yml` lets an Actions operator verify + the host after manual maintenance. -The partition matters. The bootstrap script owns installation and detection; -the composite makes it every-job hygiene; `ensure-editor.ps1` prevents a -host-level failure from masquerading as editor corruption; the workflow gives -operators a no-host-access recovery path. +The partition matters. The bootstrap script owns manual installation and +detection; the composite makes validation every-job hygiene; +`ensure-editor.ps1` prevents a host-level failure from masquerading as editor +corruption; the workflow provides a no-mutation audit. ## Prereqs Managed @@ -100,9 +102,9 @@ operators a no-host-access recovery path. - **Windows long paths**: writes `HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem!LongPathsEnabled = 1` to prevent deterministic long-path unpack failures. -- **Windows Defender exclusions**: adds allow-listed exclusions for - `C:\Unity\Editors` and the active runner workspace; skips cleanly when - Defender is unavailable. +- **Windows Defender exclusions**: adds allow-listed exclusions for the + configured Unity install root (`RUNNER_TOOL_CACHE/u6-v3` in CI) and the + active runner workspace; skips cleanly when Defender is unavailable. - **PowerShell 7 (`pwsh`)**: installs through `winget install --id Microsoft.PowerShell --scope user`, so Administrator is not required for this prereq. @@ -112,8 +114,8 @@ Microsoft.PowerShell --scope user`, so Administrator is not required for When running non-admin, HKLM-backed repairs such as both VC++ generations and long paths can fail with Access Denied. The script reports that -directly and points to the elevated local-host path or the Actions recovery -workflow. It does not use `Start-Process -Verb RunAs`; UAC prompts would +directly and points to the elevated local-host path. The Actions workflow is +validation-only. The script does not use `Start-Process -Verb RunAs`; UAC prompts would hang non-interactive CI. ## Detection Contracts @@ -142,17 +144,14 @@ Treat file-on-disk probes as authoritative when they reflect loader behavior: ## Environment Contracts -- `DXM_RUNNER_DISABLE_AUTO_BOOTSTRAP=1` forces `-DetectOnly` in the composite - and workflow, regardless of workflow input. - `DXM_RUNNER_PREREQ_INSTALLED=1` is exported by the composite after a successful preflight. `ensure-editor.ps1` reads it to distinguish "preflight never ran" from "preflight passed; investigate a different missing DLL." - `DXM_UNITY_FAKE_IMPORTS` and `DXM_UNITY_FAKE_LONGPATHS_ENABLED` are test-only seams. Production must not set them. -Mode precedence is: non-Windows host skips; env override forces detect-only; -non-truthy workflow/composite input chooses detect-only; truthy input allows -auto-install. +Workflows always use detect-only mode. Installation and repair require an +administrator running the script directly on the host. ## Verification diff --git a/docs/ops/ci-and-github-settings.md b/docs/ops/ci-and-github-settings.md index 6a5dad78..409da99b 100644 --- a/docs/ops/ci-and-github-settings.md +++ b/docs/ops/ci-and-github-settings.md @@ -64,12 +64,13 @@ the `unity-tests` `test-mode` matrix. The direct Windows runner (`scripts/unity/run-ci-tests.ps1`) maps that mode to `StandaloneWindows64` and configures IL2CPP in the generated project, not a separate job. -Unity editor provisioning must be scoped before the license lock. Every -`ensure-editor.ps1 -CiManagedOnly` workflow step passes an explicit +Unity editors and modules are installed manually by a runner administrator. +Every workflow validates the exact tool-cache editor before the license lock +with `ensure-editor.ps1 -CiManagedOnly -RequireHealthyExisting` and an explicit `-ProvisioningProfile`: `EditorOnly` for editmode, playmode, benchmarks, and release Unity checks; `StandaloneWindowsIl2Cpp` for standalone; and `Android` -only for jobs that actually need Android SDK/NDK tooling. The direct runner's -fallback uses the same mapping if `UNITY_EDITOR_PATH` is absent. +only for jobs that actually need Android SDK/NDK tooling. Workflows never +install, repair, uninstall, or quarantine editors. Per-runner Unity-cache safety is provided by each runner agent's exclusive workspace - a single self-hosted agent only ever runs one job at a time, so @@ -316,8 +317,9 @@ Workflow-shape contract checklist: `scripts/unity/run-ci-tests.ps1`. 1. Confirm each of those jobs runs `./.github/actions/validate-unity-license` before the direct Unity runner. -1. Confirm each `ensure-editor.ps1 -CiManagedOnly` provisioning step passes an - explicit `-ProvisioningProfile` matching the Unity test mode. +1. Confirm each `ensure-editor.ps1 -CiManagedOnly` validation step passes + `-RequireHealthyExisting` and an explicit `-ProvisioningProfile` matching + the Unity test mode. 1. Confirm each of those jobs releases the organization lock after the direct Unity runner with `if: always()`. 1. Confirm each of those jobs declares the uniform static label set diff --git a/docs/runbooks/unity-runners-after-transfer.md b/docs/runbooks/unity-runners-after-transfer.md index 6c7eb47d..ccd1258b 100644 --- a/docs/runbooks/unity-runners-after-transfer.md +++ b/docs/runbooks/unity-runners-after-transfer.md @@ -190,25 +190,24 @@ a small set of OS-level prerequisites installed. GitHub-hosted `windows-2022` images ship with these preinstalled; freshly imaged self-hosted runners generally do not. This section is the operator-actionable fix for that gap. -The repo ships a one-shot bootstrap script +The repo ships a manual, administrator-run bootstrap script ([`scripts/unity/bootstrap-windows-runner.ps1`](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/scripts/unity/bootstrap-windows-runner.ps1)) -and a `workflow_dispatch`-only auto-recovery workflow +and a `workflow_dispatch`-only audit workflow ([`.github/workflows/runner-bootstrap.yml`](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/.github/workflows/runner-bootstrap.yml)) plus a per-job preflight composite action ([`.github/actions/assert-unity-host-prereqs/action.yml`](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/.github/actions/assert-unity-host-prereqs/action.yml)) -that wraps the script. Together they form a four-layer defense: a one-shot -host installer, a per-job preflight that runs the same installer in -detect-or-install mode, an `ensure-editor.ps1` short-circuit that fails fast +that wraps the script in detect-only mode. Installation and repair always +require an administrator working directly on the host. The workflow and +per-job preflight only validate state. `ensure-editor.ps1` fails fast when Unity itself reports `0xC0000135` instead of looping on a futile editor -reinstall, and the operator-facing workflow that recovers the host without -RDP/SSH access. See +reinstall. See [`.llm/skills/unity-editor-ci/references/unity-runner-host-prereqs.md`](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/.llm/skills/unity-editor-ci/references/unity-runner-host-prereqs.md) for the LLM/AI-agent reference. ### Symptom -- A Unity job on a self-hosted Windows runner fails very early (typically - within the first ~6 minutes of `Provision Unity Editor`) with +- A Unity job on a self-hosted Windows runner fails very early during + `Validate installed Unity Editor` with `Unity startup provisioning probe exit code: -1073741515 (0xC0000135 / STATUS_DLL_NOT_FOUND)`. - `ensure-editor.ps1`'s provisioning summary classifies the failure as "host OS/runtime prerequisite damage", not a package/test failure. @@ -244,18 +243,9 @@ cannot help; `ensure-editor.ps1` short-circuits as soon as it sees annotation rather than burning ~13 minutes per matrix cell on a futile editor reinstall. -> **WHICH PATH TO USE.** Two operator paths follow. -> -> - **First-time fix (or any time the workflow does not yet live on the -> default branch)**: jump to **Local recovery: bootstrap script on the -> host** below. The script lives in the repo, so any local clone of any -> branch works. No GitHub Actions involvement. -> - **Every subsequent regression after the bootstrap workflow is on the -> default branch**: use **Auto-recovery: workflow_dispatch** below. -> `workflow_dispatch` triggers only register from the default branch, so -> the **Run workflow** button (and `gh workflow run`) only become -> available after this PR (or any future PR carrying -> `.github/workflows/runner-bootstrap.yml`) is merged. +> **REPAIR REQUIRES HOST ADMINISTRATOR ACCESS.** Use the local recovery +> procedure below. Afterward, dispatch the audit workflow to verify the +> runner without mutating it. `bootstrap-windows-runner.ps1` addresses three other foundational host concerns in the same pass: Windows long-path support (the prerequisite that @@ -263,43 +253,28 @@ unblocks the Android NDK 93% unpack failure described in the next section), Windows Defender exclusions for the Unity install root and the runner workspace, and PowerShell 7 (`pwsh`). -### Auto-recovery: workflow_dispatch (no host access required) +### Post-maintenance audit: workflow_dispatch -Use this path when you can read the Actions UI but cannot RDP/SSH to the -runner host. The workflow installs every prereq idempotently and uploads a -transcript artifact. - -> **HARD PREREQUISITE: `runner-bootstrap.yml` must be on the default -> branch (`master`) before this path works at all.** GitHub Actions only -> registers `workflow_dispatch` triggers from workflow files that exist on -> the default branch; until this PR is merged, the **Run workflow** button -> does NOT appear in the Actions UI and `gh workflow run runner-bootstrap.yml` -> fails with `could not find any workflows named runner-bootstrap.yml`. -> Use the **Local recovery** path below for the FIRST-TIME runner repair -> (it has no merge dependency: the script lives in the repo and runs from -> any branch's checkout). Once merged, this Actions-UI path becomes the -> low-friction option for every subsequent regression. +This path verifies a runner after an administrator has completed host-side +maintenance. It never installs or repairs prerequisites, editors, or modules. 1. (HARD-FAIL prerequisite) Take the OTHER runner offline first. Both self-hosted Windows runners share the labels `self-hosted, Windows, RAM-64GB`, so the scheduler picks either machine; this workflow HARD-FAILS - on wrong-target dispatch (exit 1, by design) to refuse silent bootstraps + on wrong-target dispatch (exit 1, by design) to refuse auditing of an unintended machine. Offline the unwanted runner by opening **Settings -> Actions -> Runners**, clicking the runner, and selecting **Remove runner** (or stop the runner service on the host with `Stop-Service actions.runner.*`), then bring it back online after the - bootstrap completes. -1. Open **Actions -> Runner Bootstrap (Windows) -> Run workflow**. -1. Pick `runner-label`: the name of the runner you want to bootstrap + audit completes. +1. Open **Actions -> Runner Audit (Windows) -> Run workflow**. +1. Pick `runner-label`: the name of the runner you want to audit (`DAD-MACHINE` or `ELI-MACHINE`). -1. Pick `detect-only`: leave `false` (the default) to auto-install every - missing prereq. Set to `true` to audit without mutating the host (the run - exits 2 if anything is missing). 1. Click **Run workflow**. -1. Wait for the run to finish (~5-10 minutes on a healthy network) and +1. Wait for the run to finish and confirm a green status. The run uploads a transcript artifact named `runner-bootstrap---`. -1. Re-run the failed Unity job. The next provisioning attempt should pass. +1. Re-run the failed Unity job. Editor validation should pass. ### Local recovery: bootstrap script on the host @@ -318,7 +293,9 @@ Use this path when you can RDP/SSH/console into the runner host. 1. Run the bootstrap script: ```powershell - .\scripts\unity\bootstrap-windows-runner.ps1 + $runnerToolCache = 'C:\path\to\actions-runner\_work\_tool' + $editorRoot = Join-Path $runnerToolCache 'u6-v3' + .\scripts\unity\bootstrap-windows-runner.ps1 -UnityInstallRoot $editorRoot ``` 1. The script detects each prereq and installs only what is missing. It is @@ -328,6 +305,29 @@ Use this path when you can RDP/SSH/console into the runner host. `LongPathsEnabled` and the `pwsh` install do require a fresh agent shell, which the next job naturally creates. +### Manual Unity editor installation + +Run editor maintenance from an elevated PowerShell session on each runner. +Use that runner's actual GitHub Actions tool-cache directory; the workflows +and central license-return action require the `u6-v3` layout exactly. + +```powershell +$runnerToolCache = 'C:\path\to\actions-runner\_work\_tool' +$editorRoot = Join-Path $runnerToolCache 'u6-v3' +.\scripts\unity\maintain-windows-runner.ps1 ` + -UnityVersions @('2021.3.45f1', '2022.3.45f1', '6000.3.16f1') ` + -InstallRoot $editorRoot ` + -ProvisioningProfile StandaloneWindowsIl2Cpp ` + -Force +``` + +This is a host-side administrator operation, not a workflow step. It installs +the Windows IL2CPP module for every canonical version, which also satisfies +the EditorOnly validation used by editmode and playmode jobs. Dispatch +**Runner Audit (Windows)** afterward; a green audit confirms the exact editor +paths, modules, host prerequisites, and startup probes without changing the +runner. + ### What the bootstrap installs The bootstrap script detects each prereq independently and remediates only @@ -357,7 +357,8 @@ final exit code reflects the worst outcome across all of them. `HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem!LongPathsEnabled = 1`. Resolves the Android NDK 93% unpack failure at the legacy MAX_PATH boundary (see the next section for the underlying root cause). -- **Windows Defender exclusions** for `C:\Unity\Editors` and the active +- **Windows Defender exclusions** for the runner tool cache's `u6-v3` editor + root and the active runner workspace (**best-effort, perf optimization**). Prevents Defender from transient-locking NDK files during unpack. Skipped gracefully when Defender is absent. Also **skipped on non-admin per-job preflight runs** @@ -365,8 +366,7 @@ final exit code reflects the worst outcome across all of them. cannot call `Add-MpPreference`); Defender management is not a correctness requirement for Unity startup, so a non-admin runner does not attempt it. To install or refresh exclusions, run the bootstrap from an elevated - shell on the host (see Local recovery above) or trigger - `runner-bootstrap.yml`. + shell on the host (see Local recovery above). - **PowerShell 7 (`pwsh`)** via `winget install --id Microsoft.PowerShell --scope user`. The `--scope user` install means Administrator is not required for `pwsh` itself. @@ -386,11 +386,15 @@ every prereq's status without mutating anything. Exit codes: - non-zero, non-2: an unrecoverable error occurred during detection. ```powershell -.\scripts\unity\bootstrap-windows-runner.ps1 -DetectOnly +$runnerToolCache = 'C:\path\to\actions-runner\_work\_tool' +$editorRoot = Join-Path $runnerToolCache 'u6-v3' +.\scripts\unity\bootstrap-windows-runner.ps1 ` + -DetectOnly ` + -UnityInstallRoot $editorRoot ``` -The same audit is available via the workflow: pick `detect-only: true` on the -`Run workflow` dialog. +The same audit is available through **Runner Audit (Windows)**. Select only +the target runner; the workflow is always detect-only. ### Verification @@ -416,7 +420,7 @@ The next Unity job's `Print runner diagnostics` step runs the per-job preflight (`assert-unity-host-prereqs`). A green preflight is the live confirmation that recovery worked. -### When auto-install fails +### When manual installation fails Common failure modes and the remediation for each: @@ -424,10 +428,8 @@ Common failure modes and the remediation for each: (2010 and 2015-2022) and `LongPathsEnabled` need `HKLM` writes. The script detects access-denied via a locale-safe exception-type check and emits an actionable `::error::` pointing at this section. Fix: re-run - the script from an elevated shell on the host, or configure the runner - agent service account with local admin rights and re-trigger the - workflow. -- **Network failure during the VC++ download.** Re-trigger the workflow. The + the script from an elevated shell on the host. +- **Network failure during the VC++ download.** Re-run the elevated host-side script. The script pins each generation's URL to its canonical Microsoft host (`https://aka.ms/vc14/vc_redist.x64.exe` for the 2015-2022 generation; `https://download.microsoft.com/download/1/6/5/165255E7-1014-4D0A-B094-B6A430A6BFFC/vcredist_x64.exe` @@ -510,24 +512,18 @@ missing list. Quarantine and reinstall: re-runs do not clobber prior quarantines): ```powershell + $runnerToolCache = 'C:\path\to\actions-runner\_work\_tool' + $editorRoot = Join-Path $runnerToolCache 'u6-v3' $ts = Get-Date -Format 'yyyyMMdd-HHmmss' - New-Item -ItemType Directory -Force -Path 'C:\Unity\Editors\_quarantine' | Out-Null - Move-Item 'C:\Unity\Editors\' "C:\Unity\Editors\_quarantine\-$ts" + $quarantine = Join-Path $editorRoot '_quarantine' + New-Item -ItemType Directory -Force -Path $quarantine | Out-Null + Move-Item (Join-Path $editorRoot '') (Join-Path $quarantine "-$ts") Start-Service actions.runner.* ``` -1. On the next CI run, `ensure-editor.ps1`'s auto-install path will - re-download Unity into the now-empty managed root. - -Alternatively, force `ensure-editor.ps1` to perform the managed -reinstall in-line from CI without a host-side operation. Set -`DXM_UNITY_FORCE_REINSTALL=1` on the re-trigger (workflow-dispatch env, -matrix env, or step-level env): the env var **bypasses the 0xC0000135 -short-circuit** and falls through to the existing repair pipeline. The -bypass exists exactly for this case (operator has confirmed the missing -DLL is Unity-shipped, not OS). Do NOT set this env var when the missing -DLL is a system library; the reinstall will not help and will burn ~6 -minutes per matrix cell. +1. While still on the host in an elevated shell, manually install the exact + editor and modules under the runner tool cache's `u6-v3` directory. CI + will only validate that install; it will not repopulate the empty root. #### If ALL imports resolve but Unity still fails 0xC0000135 @@ -605,7 +601,7 @@ Sources: - Bootstrap script: [`scripts/unity/bootstrap-windows-runner.ps1`](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/scripts/unity/bootstrap-windows-runner.ps1). - Per-job preflight composite: [`.github/actions/assert-unity-host-prereqs/action.yml`](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/.github/actions/assert-unity-host-prereqs/action.yml). -- Auto-recovery workflow: [`.github/workflows/runner-bootstrap.yml`](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/.github/workflows/runner-bootstrap.yml). +- Validation-only runner audit: [`.github/workflows/runner-bootstrap.yml`](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/.github/workflows/runner-bootstrap.yml). - LLM-agent skill: [`.llm/skills/unity-editor-ci/references/unity-runner-host-prereqs.md`](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/.llm/skills/unity-editor-ci/references/unity-runner-host-prereqs.md). - The [PowerShell 7 prerequisite](#powershell-7-prerequisite-on-self-hosted-runners) section above documents the `pwsh: command not found` failure that the @@ -657,12 +653,13 @@ on the freshly written files can also interrupt the unpack. System -> Filesystem -> Enable Win32 long paths -> Enabled**. Restart the self-hosted runner agent (and ideally reboot) so the change takes effect. -1. **(Optional) Add a Windows Defender exclusion** for the Unity install root - (`C:\Unity\Editors`) so Defender does not transiently lock NDK files during - extraction: +1. **(Optional) Add a Windows Defender exclusion** for the runner tool cache's + `u6-v3` Unity install root so Defender does not transiently lock NDK files + during extraction: ```powershell - Add-MpPreference -ExclusionPath 'C:\Unity\Editors' + $editorRoot = 'C:\path\to\actions-runner\_work\_tool\u6-v3' + Add-MpPreference -ExclusionPath $editorRoot ``` After enabling long paths, re-run the workflow. The post-mortem's diff --git a/scripts/__tests__/ci-aggregate-workflow.test.js b/scripts/__tests__/ci-aggregate-workflow.test.js index 767d9520..82cb2455 100644 --- a/scripts/__tests__/ci-aggregate-workflow.test.js +++ b/scripts/__tests__/ci-aggregate-workflow.test.js @@ -414,7 +414,18 @@ test("every Unity lock window releases with explicit cleanup proof", () => { if (file === "runner-bootstrap.yml") assert.doesNotMatch(preflight, /\n run:|\.status|\$\{\{ inputs\.runner-label/); } - + const runnerAudit = readWorkflow("runner-bootstrap.yml"); + const runnerAuditJob = getJobBlock(runnerAudit, "bootstrap", "runner-bootstrap.yml"); + assert.match(runnerAudit, /^name: Runner Audit \(Windows\)$/m); + assert.doesNotMatch(runnerAudit, /^\s+detect-only:$/m); + assert.match(runnerAuditJob, /\n\s+DetectOnly = \$true\n/); + assert.match(runnerAuditJob, /Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'/); + assert.doesNotMatch(runnerAuditJob, /\binputs\.detect-only\b/); + // prettier-ignore + const hostPrereqAction = fs.readFileSync(path.join(WORKFLOW_DIR, "..", "actions", "assert-unity-host-prereqs", "action.yml"), "utf8"); + assert.doesNotMatch(hostPrereqAction, /^\s+auto-install:$/m); + assert.match(hostPrereqAction, /& \$scriptPath -DetectOnly/); + assert.doesNotMatch(hostPrereqAction, /& \$scriptPath\s*$/m); for (const action of [acquire, returnLicense, classify, release, gate]) { const count = workflowSources.reduce((sum, source) => sum + source.split(action).length - 1, 0); assert.equal(count, UNITY_LOCK_WINDOWS.length, action); @@ -422,7 +433,7 @@ test("every Unity lock window releases with explicit cleanup proof", () => { for (const [file, jobId, licensedWorkName, emptyAware] of UNITY_LOCK_WINDOWS) { const label = `${file}:${jobId}`; - const licensedCondition = `${emptyAware ? "steps\\.compute\\.outputs\\.is-empty != 'true' && " : ""}steps\\.acquire_lock\\.outputs\\.acquired == 'true' && steps\\.provision_editor\\.outcome == 'success'`; + const licensedCondition = `${emptyAware ? "steps\\.compute\\.outputs\\.is-empty != 'true' && " : ""}steps\\.validate_editor\\.outcome == 'success' && steps\\.acquire_lock\\.outputs\\.acquired == 'true'`; const job = getJobBlock(readWorkflow(file), jobId, file); assert.match(job, /\n timeout-minutes: 900\n/, `${label}: lifecycle budget`); if (["perf-numbers.yml", "unity-benchmarks.yml", "unity-tests.yml"].includes(file)) { @@ -433,7 +444,7 @@ test("every Unity lock window releases with explicit cleanup proof", () => { } // prettier-ignore - const lifecycleNames = ["Acquire organization Unity lock", "Require acquired Unity lock", "Provision Unity Editor", licensedWorkName, "Return Unity license", "Classify Unity cleanup evidence", "Release organization Unity lock", "Require confirmed Unity cleanup"]; + const lifecycleNames = ["Validate installed Unity Editor", "Acquire organization Unity lock", "Require acquired Unity lock", licensedWorkName, "Return Unity license", "Classify Unity cleanup evidence", "Release organization Unity lock", "Require confirmed Unity cleanup"]; const positions = lifecycleNames.map((name) => job.indexOf(` - name: ${name}`)); const sortedPositions = [...positions].sort((a, b) => a - b); assert.ok( @@ -443,13 +454,16 @@ test("every Unity lock window releases with explicit cleanup proof", () => { assert.deepEqual(positions, sortedPositions, `${label} lifecycle order`); // prettier-ignore - const [acquireStep, requireStep, provisionStep, workStep, returnStep, classifyStep, releaseStep, gateStep] = lifecycleNames.map((name) => getStepBlock(job, name)); + const [validationStep, acquireStep, requireStep, workStep, returnStep, classifyStep, releaseStep, gateStep] = lifecycleNames.map((name) => getStepBlock(job, name)); // prettier-ignore const contracts = [ + [validationStep, /\n id: validate_editor\n/], + [validationStep, /\n timeout-minutes: 10\n/], + [validationStep, /-InstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, `${label}: validation and central cleanup must use the same trusted editor root`], + [validationStep, /-CiManagedOnly/], + [validationStep, /-RequireHealthyExisting/], [acquireStep, /\n id: acquire_lock\n/], - [provisionStep, /\n id: provision_editor\n/], - [provisionStep, /-InstallRoot \(Join-Path \$env:RUNNER_TOOL_CACHE 'u6-v3'\)/, `${label}: central cleanup and provisioning must use the same trusted editor root`], [requireStep, /\n if: \$\{\{ steps\.acquire_lock\.outputs\.acquired != 'true' \}\}\n[\s\S]*\n run: exit 1\n/], [workStep, new RegExp(`\\n if: \\$\\{\\{ ${licensedCondition} \\}\\}\\n`), label], [workStep, /-LicenseReturnOwner Central/, `${label}: the trusted central action must own the post-activation return`], @@ -465,11 +479,7 @@ test("every Unity lock window releases with explicit cleanup proof", () => { ]; for (const [actual, contract, message] of contracts) assert.match(actual, contract, message); - assert.doesNotMatch( - provisionStep, - /-RequireHealthyExisting/, - `${label}: provisioning must be able to populate the trusted editor root` - ); + assert.doesNotMatch(validationStep, /\b(?:install-modules|uninstall)\b/i, label); assert.doesNotMatch(returnStep, /continue-on-error:/); const acquireHolder = /holder-id-suffix: (.+)\n/.exec(acquireStep); diff --git a/scripts/unity/bootstrap-windows-runner.ps1 b/scripts/unity/bootstrap-windows-runner.ps1 index e959168b..07628dc4 100644 --- a/scripts/unity/bootstrap-windows-runner.ps1 +++ b/scripts/unity/bootstrap-windows-runner.ps1 @@ -86,8 +86,7 @@ (non-admin). On a non-admin shell, prereqs that need HKLM writes (VC++ 2010, VC++ 2015-2022, LongPathsEnabled) will fail at install time with Access Denied -- the script catches that and emits a SPECIFIC ::error:: telling - the operator to either run the script as Administrator or trigger - .github/workflows/runner-bootstrap.yml from the Actions UI. We deliberately + the operator to run the script as Administrator directly on the host. We deliberately do NOT auto-elevate via Start-Process -Verb RunAs because UAC would hang a non-interactive CI run. @@ -928,7 +927,7 @@ function Install-VcRedistModern { # parity. (The download/sig-check would still work on non-admin but the # installer would access-denied; bailing here saves the round-trip.) if (-not (Test-IsAdministrator)) { - return @{ success = $false; reason = "access denied (likely non-admin): VC++ 2015-2022 redist install requires Administrator. Run the bootstrap from an elevated shell or trigger .github/workflows/runner-bootstrap.yml." } + return @{ success = $false; reason = "access denied (likely non-admin): VC++ 2015-2022 redist install requires Administrator. Run the bootstrap from an elevated shell on the host." } } $download = Invoke-VcRedistModernDownload -Url $Url -TimeoutSeconds $DownloadTimeoutSeconds @@ -1018,7 +1017,7 @@ function Install-VcRedist2010 { # deterministic error path. Mirrors the access-denied posture of the # modern installer. if (-not (Test-IsAdministrator)) { - return @{ success = $false; reason = "access denied (likely non-admin): VC++ 2010 redist install requires Administrator. Run the bootstrap from an elevated shell or trigger .github/workflows/runner-bootstrap.yml." } + return @{ success = $false; reason = "access denied (likely non-admin): VC++ 2010 redist install requires Administrator. Run the bootstrap from an elevated shell on the host." } } # Idempotent skip: if Test-VcRedist2010Installed already reports success, @@ -1798,7 +1797,7 @@ function Invoke-WindowsRunnerBootstrap { Write-CiNotice "bootstrap-windows-runner.ps1 detected non-Windows host; nothing to do in DetectOnly mode. skipping (not Windows)" return 0 } - Write-CiError "bootstrap-windows-runner.ps1 is Windows-only. Detected directory separator '$([System.IO.Path]::DirectorySeparatorChar)' which is not '\'. Run this on a self-hosted Windows runner or via .github/workflows/runner-bootstrap.yml." + Write-CiError "bootstrap-windows-runner.ps1 is Windows-only. Detected directory separator '$([System.IO.Path]::DirectorySeparatorChar)' which is not '\'. Run this directly on the self-hosted Windows runner." return 1 } @@ -1815,7 +1814,7 @@ function Invoke-WindowsRunnerBootstrap { # best-effort (Defender, which is now SKIPPED on non-admin per the # 2026-05-26 tiering fix -- prior code treated Defender's non-admin # failure as a critical failure and exited 1). - Write-CiWarning "bootstrap-windows-runner.ps1 running NON-admin. Critical prereqs that require HKLM writes (VC++ 2010 redist install, VC++ 2015-2022 redist install, LongPathsEnabled) will fail with Access Denied; best-effort prereqs that require admin (Defender exclusions) are SKIPPED with a notice. To install missing critical prereqs, run from an elevated shell, OR trigger .github/workflows/runner-bootstrap.yml from the Actions UI." + Write-CiWarning "bootstrap-windows-runner.ps1 running NON-admin. Critical prereqs that require HKLM writes (VC++ 2010 redist install, VC++ 2015-2022 redist install, LongPathsEnabled) will fail with Access Denied; best-effort prereqs that require admin (Defender exclusions) are SKIPPED with a notice. To install missing critical prereqs, run from an elevated shell on the host." } $results = New-Object 'System.Collections.Generic.List[object]' @@ -1889,8 +1888,8 @@ function Invoke-WindowsRunnerBootstrap { # Tier-aware exit-code resolution. Failures are partitioned into: # - CRITICAL: load-bearing for Unity correctness (vcredist-2010, # vcredist-2015-2022, long-paths, pwsh, ucrt). A critical failure exits - # 1 and the operator MUST remediate (elevated re-run or - # workflow_dispatch) before the next Unity job can pass. + # 1 and the operator MUST remediate with an elevated host-side re-run + # before the next Unity job can pass. # - BEST-EFFORT: perf optimizations or convenience (defender-exclusion). # A best-effort failure produces a ::warning:: and the dispatcher # continues -- Unity correctness is unaffected. This is the @@ -1951,7 +1950,7 @@ function Invoke-WindowsRunnerBootstrap { } if ($criticalFailed.Count -gt 0) { - Write-CiError "bootstrap-windows-runner: one or more CRITICAL prereqs failed to install. See individual ::error:: lines above. To remediate: run this script from an elevated PowerShell, or trigger .github/workflows/runner-bootstrap.yml from the Actions UI." + Write-CiError "bootstrap-windows-runner: one or more CRITICAL prereqs failed to install. See individual ::error:: lines above. To remediate, run this script from an elevated PowerShell directly on the host." return 1 } if ($DetectOnly -and $anyMissing) { diff --git a/scripts/unity/ensure-editor.ps1 b/scripts/unity/ensure-editor.ps1 index 20c3c2bb..57b692a6 100644 --- a/scripts/unity/ensure-editor.ps1 +++ b/scripts/unity/ensure-editor.ps1 @@ -776,16 +776,21 @@ function Get-UnityEditorCandidates { $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") - ) + if ($IncludeHostInstalls) { + # The flat root layout is a legacy host-install shape. CI-managed + # installs deliberately exclude it because the central license-return + # action resolves only \\Editor\Unity.exe. + $candidates.Add((Join-Path $Root "$Version\Unity.exe")) + if (${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 (${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 }) @@ -1574,7 +1579,7 @@ 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 + # from an elevated host shell) 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 @@ -1595,14 +1600,14 @@ function Write-UnityHostPrereqAnnotation { # 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 DXM_RUNNER_PREREQ_INSTALLED=1 to the rest of the job. In that case the + # CONTEXT-AWARE CAUSE PHRASING: when the validation-only composite preflight + # (`assert-unity-host-prereqs`) has confirmed VC++ at job start, it exports + # DXM_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 -- + # (preflight just confirmed it); 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 + # VC++" after preflight already confirmed it. 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 @@ -1665,8 +1670,7 @@ function Write-UnityHostPrereqAnnotation { # 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 DXM_UNITY_FORCE_REINSTALL=1 as the - # operator-actionable override for the 0xC0000135 short-circuit. + # case and direct the runner administrator to host-side repair. $unityShippedMissing = @($missingList | Where-Object { Test-UnityImportLooksUnityShipped -Name $_ }) $missingNames = ($missingList -join ', ') # R1 (round-3 review nit): segments do NOT end with `.` -- the @@ -1678,7 +1682,7 @@ function Write-UnityHostPrereqAnnotation { $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 DXM_UNITY_FORCE_REINSTALL=1 to override the 0xC0000135 short-circuit and let ensure-editor.ps1 perform a managed reinstall)" + $missingSegment += "; some missing DLLs ($($unityShippedMissing -join ', ')) appear to be Unity-shipped third-party libraries, suggesting the Unity install at $editorDirForHint is partial or corrupt -- a runner administrator must quarantine and reinstall it from an elevated host shell" } $diagnosticSegment = $missingSegment } else { @@ -1764,7 +1768,7 @@ function Write-UnityHostPrereqAnnotation { $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)." + "Fix: a runner administrator must run scripts/unity/bootstrap-windows-runner.ps1 from an elevated shell on this host. 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 @@ -1787,7 +1791,7 @@ function Write-UnityHostPrereqAnnotation { try { $fallbackPreflight = ($env:DXM_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." + Write-Host "::error title=Unity $Version host prerequisite missing::Unity $Version native startup failed with exit $ExitCode. Preflight already confirmed 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." } @@ -2084,32 +2088,36 @@ function Resolve-InstalledEditor { [switch]$ManagedOnly ) + if ($ManagedOnly) { + # The central license-return action uses this exact leaf. Managed + # maintenance must converge on the same layout that CI validates; + # accepting \\Unity.exe here would let host repair + # report success for an editor that licensed workflows cannot use. + $canonicalEditor = Join-Path $Root "$Version\Editor\Unity.exe" + if (Test-Path -LiteralPath $canonicalEditor -PathType Leaf) { + return (Resolve-Path -LiteralPath $canonicalEditor).Path + } + return $null + } + $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) + $byCandidate = Find-UnityEditor -Version $Version -Root $Root -IncludeHostInstalls 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 } @@ -3732,7 +3740,7 @@ function Ensure-UnityNativeStartupHealthy { Write-CiNotice "DXM_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 DXM_UNITY_FORCE_REINSTALL=1 to bypass this short-circuit and retry with a managed reinstall." + 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: a runner administrator must repair the host or editor from an elevated host shell." } } @@ -3758,7 +3766,7 @@ function Ensure-UnityNativeStartupHealthy { # 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)). 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: a runner administrator must repair the host from an elevated shell." } 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" } @@ -4519,20 +4527,34 @@ Initialize-UnityProvisioningBudget Write-CiNotice "Unity editor provisioning profile: $ProvisioningProfile." try { -New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null +if (-not $RequireHealthyExisting) { + New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null +} -$editor = Find-UnityEditor -Version $UnityVersion -Root $InstallRoot -IncludeHostInstalls:(-not $CiManagedOnly) +$editor = if ($RequireHealthyExisting -and $CiManagedOnly) { + # SYNC: The central return-unity-license action resolves this exact trusted + # leaf. A flat \\Unity.exe can run tests but cannot return + # the paid seat through that action, so CI validation must reject it. + $canonicalEditor = Join-Path $InstallRoot "$UnityVersion\Editor\Unity.exe" + if (Test-Path -LiteralPath $canonicalEditor -PathType Leaf) { + (Resolve-Path -LiteralPath $canonicalEditor).Path + } else { + $null + } +} else { + 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." + throw "Unity $UnityVersion is not installed at the required canonical path '$InstallRoot\$UnityVersion\Editor\Unity.exe'. CI never installs or repairs Unity. A runner administrator must manually install the exact editor and required modules at that path before retrying." } $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." + throw "Unity $UnityVersion is missing required CI module groups for profile '$ProvisioningProfile': $($missingModules -join ', '). CI never installs modules. A runner administrator must add them manually before retrying." } if ($env:DXM_UNITY_SKIP_NATIVE_STARTUP_PROBE -eq '1') { @@ -4545,7 +4567,7 @@ if ($RequireHealthyExisting) { 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" + throw "Unity $UnityVersion native startup probe failed with exit code $($startupResult.ExitCode) ($($startupResult.Description)). CI never repairs Unity. A runner administrator must repair the host or editor manually before retrying. Probe log: $probeLog" } } $script:ProvisioningEditorPath = $editor diff --git a/scripts/unity/export-unitypackage.ps1 b/scripts/unity/export-unitypackage.ps1 index 400352db..c17deb6a 100644 --- a/scripts/unity/export-unitypackage.ps1 +++ b/scripts/unity/export-unitypackage.ps1 @@ -597,7 +597,7 @@ $OutputPath = Resolve-FullPath -Path $OutputPath if (-not $StageOnly) { if ([string]::IsNullOrWhiteSpace($UnityEditorPath)) { - Write-CiError 'UnityEditorPath is required (set UNITY_EDITOR_PATH or pass -UnityEditorPath); run scripts/unity/ensure-editor.ps1 first.' + Write-CiError 'UnityEditorPath is required (set UNITY_EDITOR_PATH or pass -UnityEditorPath); CI must validate a manually installed editor with ensure-editor.ps1 -RequireHealthyExisting first.' exit 1 } if (-not (Test-Path -LiteralPath $UnityEditorPath -PathType Leaf)) { diff --git a/scripts/unity/maintain-windows-runner.ps1 b/scripts/unity/maintain-windows-runner.ps1 index 6c6fa357..c9d7358f 100644 --- a/scripts/unity/maintain-windows-runner.ps1 +++ b/scripts/unity/maintain-windows-runner.ps1 @@ -252,6 +252,12 @@ function Invoke-WindowsRunnerMaintenance { } $busyProcesses = @(Get-RunnerBusyProcesses) + if ($DetectOnly) { + # An Actions audit necessarily runs inside Runner.Worker. Ignore + # that host process in read-only mode, but still refuse to probe + # while a Unity process may be using the editor. + $busyProcesses = @($busyProcesses | Where-Object { $_.name -ne 'Runner.Worker' }) + } if ($busyProcesses.Count -gt 0 -and -not $Force) { $details = ($busyProcesses | ForEach-Object { "$($_.name)[$($_.id)]" }) -join ', ' Write-CiWarning "Runner maintenance skipped because runner/editor processes are active: $details. Re-run with -Force only from an intentionally dedicated maintenance window." @@ -270,9 +276,9 @@ function Invoke-WindowsRunnerMaintenance { Write-CiNotice "Running Windows runner host bootstrap before Unity provisioning (mode=$mode)." $global:LASTEXITCODE = 0 if ($DetectOnly) { - & $bootstrap -DetectOnly + & $bootstrap -DetectOnly -UnityInstallRoot $InstallRoot } else { - & $bootstrap + & $bootstrap -UnityInstallRoot $InstallRoot } $summary.hostBootstrapExit = $LASTEXITCODE diff --git a/scripts/unity/run-ci-tests.ps1 b/scripts/unity/run-ci-tests.ps1 index 5797b837..a0c577e3 100644 --- a/scripts/unity/run-ci-tests.ps1 +++ b/scripts/unity/run-ci-tests.ps1 @@ -2474,7 +2474,7 @@ function Invoke-UnityNativeStartupProbe { 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)." + throw "Unity native startup probe failed with exit code $exitCode ($description) after the pre-lock healthy-existing editor check. CI never repairs editors. A runner administrator must repair the host or editor manually, then retry. See the streamed probe log above (also saved to $LogPath)." } } diff --git a/scripts/validate-unity-pr-policy.py b/scripts/validate-unity-pr-policy.py index eec80ebb..1a897828 100644 --- a/scripts/validate-unity-pr-policy.py +++ b/scripts/validate-unity-pr-policy.py @@ -3,6 +3,7 @@ from __future__ import annotations +import json import os import re import subprocess @@ -48,6 +49,45 @@ BLANKET_PR_REJECTION = re.compile( r"github\.event_name\s*!=\s*'pull_request'\s*&&" ) +WORKFLOW_EDITOR_MUTATION = re.compile( + r"^\s*(?:" + r"(?:Start-Process(?:\s+-FilePath)?|&|cmd(?:\.exe)?\s+/[ck])\s+" + r"['\"]?(?:[^'\"\s]*[\\/])?unity(?:\.exe)?['\"]?(?=\s|$).*$" + r"|['\"]?(?:[^'\"\s]*[\\/])?unity(?:\.exe)?['\"]?(?=\s|$)" + r"[^\n]*\b(?:install|install-modules|uninstall)\b.*$" + r"|(?:(?:Start-Process(?:\s+-FilePath)?|&|cmd(?:\.exe)?\s+/[ck])\s+)?" + r"\$[A-Za-z_][A-Za-z0-9_]*(?=\s|$)[^\n]*" + r"\b(?:install|install-modules|uninstall)\b.*$" + r")", + re.IGNORECASE | re.MULTILINE, +) +YAML_RUN_EXTRACTOR = r""" +const fs = require("fs"); +const YAML = require("yaml"); +const runs = []; +function resolved(node, document) { + return YAML.isAlias(node) ? node.resolve(document) : node; +} +function visit(node, document) { + node = resolved(node, document); + if (YAML.isMap(node)) { + for (const pair of node.items) { + const value = resolved(pair.value, document); + if (YAML.isScalar(pair.key) && pair.key.value === "run" && YAML.isScalar(value)) { + runs.push(String(value.value ?? "")); + } + visit(value, document); + } + } else if (YAML.isSeq(node)) { + for (const item of node.items) visit(item, document); + } +} +for (const document of YAML.parseAllDocuments(fs.readFileSync(0, "utf8"), { uniqueKeys: false })) { + if (document.errors.length) throw document.errors[0]; + visit(document.contents, document); +} +process.stdout.write(JSON.stringify(runs)); +""" def require(condition: bool, message: str) -> None: @@ -149,6 +189,479 @@ def positive_timeout(source: str, indentation: int, label: str) -> int: return int(matches[0]) +def containing_named_step(source: str, offset: int) -> str: + starts = [ + (match.start(), len(match.group("indent"))) + for match in re.finditer( + r"^(?P[ \t]*)- name: .+$", + source, + re.MULTILINE, + ) + if match.start() <= offset + ] + require(bool(starts), "automation call must be inside a named step") + start, indentation = starts[-1] + following = re.search( + rf"^{' ' * indentation}- name: .+$", + source[offset:], + re.MULTILINE, + ) + end = offset + following.start() if following else len(source) + return source[start:end] + + +def executable_powershell(source: str) -> str: + lines: list[str] = [] + block_comment = False + here_terminator: str | None = None + for line in source.splitlines(): + if here_terminator is not None: + if line.strip() == here_terminator: + here_terminator = None + continue + executable: list[str] = [] + quote: str | None = None + index = 0 + while index < len(line): + if block_comment: + end = line.find("#>", index) + if end < 0: + break + block_comment = False + index = end + 2 + continue + character = line[index] + if character == "`": + executable.append(line[index : index + 2]) + index += 2 + continue + if quote is not None: + executable.append(character) + if character == quote: + if ( + quote == "'" + and index + 1 < len(line) + and line[index + 1] == "'" + ): + executable.append("'") + index += 2 + continue + quote = None + index += 1 + continue + if line.startswith("<#", index): + block_comment = True + index += 2 + continue + if character == "#": + break + executable.append(character) + if character in {"'", '"'}: + quote = character + index += 1 + code = "".join(executable).rstrip() + here_start = re.search(r"@(?P['\"])\s*$", code) + if here_start is not None: + here_terminator = here_start.group("quote") + "@" + code = code[: here_start.start()] + "''" + if code.strip(): + lines.append(code) + return re.sub(r"`\s*\n\s*", " ", "\n".join(lines)) + + +def powershell_syntax(source: str) -> str: + """Remove string contents so diagnostics cannot satisfy command guards.""" + syntax: list[str] = [] + quote: str | None = None + index = 0 + while index < len(source): + character = source[index] + if character == "`": + syntax.extend(" ") + index += 2 + continue + if quote is not None: + if character == quote: + if ( + quote == "'" + and index + 1 < len(source) + and source[index + 1] == "'" + ): + syntax.extend(" ") + index += 2 + continue + quote = None + syntax.append(" ") + index += 1 + continue + if character in {"'", '"'}: + quote = character + syntax.append(" ") + else: + syntax.append(character) + index += 1 + return "".join(syntax) + + +def workflow_run_scripts(source: str) -> list[str]: + """Return every YAML run scalar, including flow-style and duplicate keys.""" + result = subprocess.run( + ["node", "-e", YAML_RUN_EXTRACTOR], + input=source, + text=True, + capture_output=True, + check=False, + ) + require( + result.returncode == 0, + "could not parse workflow YAML while enforcing editor mutation policy: " + + result.stderr.strip(), + ) + scripts = json.loads(result.stdout) + require( + isinstance(scripts, list) and all(isinstance(item, str) for item in scripts), + "workflow YAML run extractor returned an invalid payload", + ) + return scripts + + +def has_positive_switch(invocation: str, switch: str) -> bool: + syntax = powershell_syntax(invocation) + return ( + re.search( + rf"-{re.escape(switch)}(?:" + rf"\s*:\s*\$true(?=\s|`|$)" + rf"|(?=\s|`|$)" + rf")", + syntax, + re.IGNORECASE, + ) + is not None + and re.search( + rf"-{re.escape(switch)}\s*:\s*(?!\$true(?=\s|`|$))\S+", + syntax, + re.IGNORECASE, + ) + is None + ) + + +def has_positive_detect_only(source: str) -> bool: + return has_positive_switch(source, "DetectOnly") or ( + re.search( + r"^\s*DetectOnly\s*=\s*\$true\s*$", + source, + re.IGNORECASE | re.MULTILINE, + ) + is not None + ) + + +def has_nonpositive_detect_only(source: str) -> bool: + return ( + re.search( + r"(?:" + r"-DetectOnly\s*:\s*(?!\$true(?=\s|$))\S+" + r"|^\s*(?:" + r"DetectOnly" + r"|\$[A-Za-z_][A-Za-z0-9_]*\.DetectOnly" + r"|\$[A-Za-z_][A-Za-z0-9_]*\[['\"]DetectOnly['\"]\]" + r")\s*=\s*(?!\$true\s*$)\S.*$" + r")", + source, + re.IGNORECASE | re.MULTILINE, + ) + is not None + ) + + +def find_workflow_editor_mutations(files: dict[str, str]) -> list[str]: + violations: list[str] = [] + for file, source in files.items(): + for run_script_source in workflow_run_scripts(source): + executable_source = executable_powershell(run_script_source) + executable_syntax = powershell_syntax(executable_source) + if re.search( + r"\b(?:Microsoft\.PowerShell\.Utility\\)?" + r"(?:Invoke-Expression|iex)\b", + executable_syntax, + re.IGNORECASE, + ): + violations.append( + f"{file}: Invoke-Expression is forbidden in workflow run bodies" + ) + if re.search( + r"\bGet-Command\b[^\n]*\bunity(?:\.exe)?\b[^\n]*" + r"\b(?:install|install-modules|uninstall)\b", + executable_syntax, + re.IGNORECASE | re.MULTILINE, + ): + violations.append( + f"{file}: resolved Unity editor mutation command" + ) + if re.search( + r"(?:&|\.|Start-Process|saps|start)\s+" + r"\((?:Get-Command|gcm)\b", + executable_syntax, + re.IGNORECASE, + ): + violations.append( + f"{file}: dynamic resolved-command invocation is forbidden" + ) + literal_unity_mutation = re.search( + r"\bunity(?:\.exe)?\b[^\n]*" + r"\b(?:install|install-modules|uninstall)\b", + executable_syntax, + re.IGNORECASE | re.MULTILINE, + ) is not None + if re.search( + r"\b(?:pwsh|powershell)(?:\.exe)?\b[^\n]*" + r"-Command\s+['\"][^'\"]*\bunity(?:\.exe)?\b" + r"[^'\"]*\b(?:install|install-modules|uninstall)\b", + executable_source, + re.IGNORECASE | re.MULTILINE, + ): + violations.append( + f"{file}: nested Unity editor mutation command" + ) + if re.search( + r"\b(?:Start-Process|saps|start)\b[^\n]*" + r"\b(?:pwsh|powershell)(?:\.exe)?\b[^\n]*" + r"\bunity(?:\.exe)?\b[^\n]*" + r"\b(?:install|install-modules|uninstall)\b", + executable_source, + re.IGNORECASE | re.MULTILINE, + ): + violations.append( + f"{file}: nested Unity editor mutation process" + ) + ensure_mentions = re.findall( + r"ensure-editor\.ps1\b", + executable_source, + re.IGNORECASE, + ) + if ensure_mentions: + if len(ensure_mentions) != 1: + violations.append( + f"{file}: editor validation must reference " + "ensure-editor.ps1 exactly once per run body" + ) + ensure_line = next( + line + for line in executable_source.splitlines() + if re.search( + r"ensure-editor\.ps1\b", + line, + re.IGNORECASE, + ) + ) + ensure_syntax = powershell_syntax(ensure_line) + for switch in ("RequireHealthyExisting", "CiManagedOnly"): + if not has_positive_switch(ensure_syntax, switch): + violations.append( + f"{file}: ensure-editor call missing positive -{switch}" + ) + if "-ProvisioningProfile" not in ensure_syntax: + violations.append( + f"{file}: ensure-editor call missing -ProvisioningProfile" + ) + if re.search( + r"-InstallRoot\s+\(\s*Join-Path\s+" + r"\$env:RUNNER_TOOL_CACHE\s+['\"]u6-v3['\"]\s*\)", + ensure_line, + re.IGNORECASE, + ) is None: + violations.append( + f"{file}: ensure-editor call missing canonical " + "RUNNER_TOOL_CACHE/u6-v3 -InstallRoot" + ) + direct_mutation = ( + WORKFLOW_EDITOR_MUTATION.search(executable_source) is not None + or literal_unity_mutation + ) + if direct_mutation: + violations.append(f"{file}: direct Unity editor mutation command") + if not direct_mutation: + for variable_call in re.finditer( + r"(?:" + r"(?P&|\.)" + r"|Start-Process" + r"|saps" + r"|start" + r"|cmd(?:\.exe)?\s+/[ck]" + r")\s+(?:-FilePath\s+)?\$(?:" + r"\{(?P[A-Za-z_][A-Za-z0-9_]*)\}" + r"|(?P[A-Za-z_][A-Za-z0-9_]*)" + r")\b(?P.*)$", + executable_syntax, + re.IGNORECASE | re.MULTILINE, + ): + variable = ( + variable_call.group("braced") + or variable_call.group("plain") + ) + assignment = re.search( + rf"^\s*\${re.escape(variable)}\s*=.*" + r"(?:maintain|bootstrap)-windows-runner\.ps1.*$", + executable_source, + re.IGNORECASE | re.MULTILINE, + ) + dot_sources_validated_function = ( + variable_call.group("operator") == "." + and assignment is not None + and re.search( + r"\bInvoke-WindowsRunner(?:Maintenance|Bootstrap)\b", + executable_source, + re.IGNORECASE, + ) + is not None + ) + approved_detect_only_call = ( + assignment is not None + and has_positive_detect_only( + variable_call.group("arguments") + ) + and not has_nonpositive_detect_only( + variable_call.group("arguments") + ) + ) + if not ( + dot_sources_validated_function + or approved_detect_only_call + ): + violations.append( + f"{file}: variable command invocation is not an " + "approved detect-only runner audit" + ) + for script, function in ( + ( + "maintain-windows-runner.ps1", + "Invoke-WindowsRunnerMaintenance", + ), + ( + "bootstrap-windows-runner.ps1", + "Invoke-WindowsRunnerBootstrap", + ), + ): + script_mentions = list( + re.finditer( + rf"{re.escape(script)}\b", + executable_source, + re.IGNORECASE, + ) + ) + function_mentions = list( + re.finditer( + rf"{re.escape(function)}\b", + executable_source, + re.IGNORECASE, + ) + ) + if not script_mentions and not function_mentions: + continue + if len(script_mentions) > 1 or len(function_mentions) > 1: + violations.append( + f"{file}: {script} mutation surface appears more than once" + ) + continue + if ( + not has_positive_detect_only(executable_source) + or has_nonpositive_detect_only(executable_source) + ): + violations.append( + f"{file}: {script} call is not detect-only" + ) + continue + if function_mentions: + function_call = re.search( + rf"^\s*(?:\$[A-Za-z_][A-Za-z0-9_]*\s*=\s*)?" + rf"{re.escape(function)}\b(?P.*)$", + executable_source, + re.IGNORECASE | re.MULTILINE, + ) + if function_call is None: + violations.append( + f"{file}: {function} is referenced but not invoked directly" + ) + continue + arguments = function_call.group("arguments") + function_is_detect_only = ( + has_positive_switch(arguments, "DetectOnly") + and not has_nonpositive_detect_only(arguments) + ) + splat = re.search( + r"@(?P[A-Za-z_][A-Za-z0-9_]*)\b", + arguments, + ) + if splat is not None: + variable = splat.group("variable") + hashtable = re.search( + rf"^\s*\${re.escape(variable)}\s*=\s*@\{{" + rf"(?P.*?)^\s*\}}", + executable_source, + re.IGNORECASE | re.MULTILINE | re.DOTALL, + ) + function_is_detect_only = ( + hashtable is not None + and has_positive_detect_only(hashtable.group("body")) + and not has_nonpositive_detect_only( + hashtable.group("body") + ) + and executable_source[ + hashtable.end() : function_call.start() + ].strip() + == "" + and len( + re.findall( + rf"\${re.escape(variable)}\b", + executable_source, + re.IGNORECASE, + ) + ) + == 1 + ) + if not function_is_detect_only: + violations.append( + f"{file}: {function} invocation is not detect-only" + ) + continue + assignment = re.search( + rf"^\s*\$(?P[A-Za-z_][A-Za-z0-9_]*)\s*=.*" + rf"{re.escape(script)}.*$", + executable_source, + re.IGNORECASE | re.MULTILINE, + ) + if assignment is None: + continue + variable = assignment.group("variable") + calls = list( + re.finditer( + rf"^\s*(?:" + rf"&\s+\${re.escape(variable)}\b" + rf"|(?:pwsh|powershell)(?:\.exe)?\b" + rf"[^\n]*?-(?:File|Command)\b[^\n]*" + rf"\${re.escape(variable)}\b" + rf").*$", + executable_source, + re.IGNORECASE | re.MULTILINE, + ) + ) + if len(calls) > 1: + violations.append( + f"{file}: {script} assigned command is invoked more than once" + ) + for call in calls: + call_line = call.group(0) + if ( + not has_positive_detect_only(call_line) + or has_nonpositive_detect_only(call_line) + ): + violations.append( + f"{file}: assigned {script} call is not detect-only" + ) + return violations + + def validate_lock_window_timeout_budget(job: str, label: str) -> None: steps = top_level_steps_through_cleanup_gate(job, label) bounded_minutes = 0 @@ -414,6 +927,562 @@ def validate() -> None: "unregistered credential-bearing or activation-capable Unity automation: " + ", ".join(unregistered), ) + active_automation_paths = [ + *Path(".github/workflows").glob("*.yml"), + *Path(".github/workflows").glob("*.yaml"), + *Path(".github/actions").glob("*/action.yml"), + *Path(".github/actions").glob("*/action.yaml"), + ] + active_automation = { + path.as_posix(): path.read_text(encoding="utf-8") + for path in active_automation_paths + } + workflow_editor_mutations = find_workflow_editor_mutations(active_automation) + require( + not workflow_editor_mutations, + "workflow editor mutation policy failed: " + + "; ".join(workflow_editor_mutations), + ) + validation_fixture = """steps: + - name: Validate installed Unity Editor + run: | + ./scripts/unity/ensure-editor.ps1 -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') -CiManagedOnly -ProvisioningProfile EditorOnly -RequireHealthyExisting +""" + missing_guard_fixture = validation_fixture.replace( + " -RequireHealthyExisting", + "", + ) + direct_install_fixture = """steps: + - name: Install editor + run: | + unity install 6000.3.16f1 +""" + maintenance_fixture = """steps: + - name: Maintain runner + run: ./scripts/unity/maintain-windows-runner.ps1 +""" + require( + find_workflow_editor_mutations({"valid.yml": validation_fixture}) == [], + "validation-only editor fixture must pass", + ) + require( + find_workflow_editor_mutations({"missing.yml": missing_guard_fixture}) + == [ + "missing.yml: ensure-editor call missing positive -RequireHealthyExisting" + ], + "missing healthy-existing guard fixture must fail", + ) + false_switch_fixture = validation_fixture.replace( + "-CiManagedOnly", + "-CiManagedOnly:$false", + ).replace( + "-RequireHealthyExisting", + "-RequireHealthyExisting:$false", + ) + require( + find_workflow_editor_mutations({"false.yml": false_switch_fixture}) + == [ + "false.yml: ensure-editor call missing positive -RequireHealthyExisting", + "false.yml: ensure-editor call missing positive -CiManagedOnly", + ], + "false-valued validation switches must fail", + ) + inline_comment_fixture = """steps: + - name: Unsafe validation + run: ./scripts/unity/ensure-editor.ps1 -UnityVersion 6000.3.16f1 # -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') -CiManagedOnly -ProvisioningProfile EditorOnly -RequireHealthyExisting +""" + inline_comment_violations = find_workflow_editor_mutations( + {"inline-comment.yml": inline_comment_fixture} + ) + require( + len(inline_comment_violations) == 4 + and all( + "ensure-editor call missing" in item + for item in inline_comment_violations + ), + "inline PowerShell comments must not satisfy editor validation guards", + ) + duplicate_call_fixture = """steps: + - name: Validate installed Unity Editor + run: | + ./scripts/unity/ensure-editor.ps1 -InstallRoot (Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3') -CiManagedOnly -ProvisioningProfile EditorOnly -RequireHealthyExisting + ./scripts/unity/ensure-editor.ps1 -UnityVersion 6000.3.16f1 +""" + duplicate_violations = find_workflow_editor_mutations( + {"duplicate.yml": duplicate_call_fixture} + ) + require( + duplicate_violations + == [ + "duplicate.yml: editor validation must reference " + "ensure-editor.ps1 exactly once per run body" + ], + "each editor-validation run body must contain exactly one ensure-editor reference", + ) + prose_fixture = """description: unity install is forbidden in CI +steps: + - name: Explain policy + run: Write-Output 'validation only' +""" + require( + find_workflow_editor_mutations({"prose.yml": prose_fixture}) == [], + "non-executable YAML prose must not be treated as an editor mutation", + ) + require( + find_workflow_editor_mutations({"install.yml": direct_install_fixture}) + == ["install.yml: direct Unity editor mutation command"], + "direct editor install fixture must fail", + ) + for name, command in ( + ("start-process", "Start-Process unity -ArgumentList 'install','6000.3.16f1'"), + ( + "start-process-file-path", + "Start-Process -FilePath unity -ArgumentList 'install','6000.3.16f1'", + ), + ("quoted", "& 'unity' install 6000.3.16f1"), + ("variable", "& $unity install 6000.3.16f1"), + ("arbitrary-variable", "& $cli install 6000.3.16f1"), + ("absolute", "& 'C:\\Tools\\Unity.exe' install 6000.3.16f1"), + ("cmd", "cmd /c unity install 6000.3.16f1"), + ): + mutation_fixture = ( + "steps:\n - name: mutate\n run: |\n" + f" {command}\n" + ) + require( + find_workflow_editor_mutations({f"{name}.yml": mutation_fixture}) + == [f"{name}.yml: direct Unity editor mutation command"], + f"{name} editor mutation fixture must fail", + ) + invoke_expression_fixture = """steps: + - name: mutate + run: Invoke-Expression 'unity install 6000.3.16f1' +""" + require( + any( + "Invoke-Expression is forbidden" in violation + for violation in find_workflow_editor_mutations( + {"invoke-expression.yml": invoke_expression_fixture} + ) + ), + "Invoke-Expression editor mutation fixture must fail", + ) + folded_install_fixture = """steps: + - name: mutate + run: > + unity + install 6000.3.16f1 +""" + require( + find_workflow_editor_mutations({"folded.yml": folded_install_fixture}) + == ["folded.yml: direct Unity editor mutation command"], + "folded YAML editor mutation fixture must fail", + ) + require( + find_workflow_editor_mutations({"maintenance.yml": maintenance_fixture}) + == [ + "maintenance.yml: maintain-windows-runner.ps1 call is not detect-only" + ], + "workflow runner maintenance fixture must fail", + ) + indirect_maintenance_fixture = """steps: + - name: Maintain runner + run: | + $script = Join-Path scripts unity/maintain-windows-runner.ps1 + & $script +""" + require( + any( + "maintain-windows-runner.ps1 call is not detect-only" in violation + for violation in find_workflow_editor_mutations( + {"indirect-maintenance.yml": indirect_maintenance_fixture} + ) + ), + "indirect workflow runner maintenance fixture must fail", + ) + invocation_bypass_fixtures = { + "dot-source-ensure": ( + ". ./scripts/unity/ensure-editor.ps1 -UnityVersion 6000.3.16f1", + "ensure-editor call missing positive -RequireHealthyExisting", + ), + "command-ensure": ( + "pwsh -Command ./scripts/unity/ensure-editor.ps1 -UnityVersion 6000.3.16f1", + "ensure-editor call missing positive -RequireHealthyExisting", + ), + "workspace-ensure": ( + '& "$env:GITHUB_WORKSPACE/scripts/unity/ensure-editor.ps1" ' + "-UnityVersion 6000.3.16f1", + "ensure-editor call missing positive -RequireHealthyExisting", + ), + "command-maintenance": ( + "pwsh -Command ./scripts/unity/maintain-windows-runner.ps1", + "maintain-windows-runner.ps1 call is not detect-only", + ), + } + for name, (command, expected_fragment) in invocation_bypass_fixtures.items(): + fixture = ( + "steps:\n - name: unsafe\n run: |\n" + f" {command}\n" + ) + require( + any( + expected_fragment in violation + for violation in find_workflow_editor_mutations( + {f"{name}.yml": fixture} + ) + ), + f"{name} invocation bypass fixture must fail", + ) + false_function_fixture = """steps: + - name: Unsafe maintenance + run: | + . ./scripts/unity/maintain-windows-runner.ps1 + Invoke-WindowsRunnerMaintenance -DetectOnly:$false +""" + require( + find_workflow_editor_mutations( + {"false-function.yml": false_function_fixture} + ) + == [ + "false-function.yml: " + "maintain-windows-runner.ps1 call is not detect-only" + ], + "false-valued maintenance function invocation must fail", + ) + repeated_maintenance_fixture = """steps: + - name: Unsafe repeated maintenance + run: | + ./scripts/unity/maintain-windows-runner.ps1 -DetectOnly + ./scripts/unity/maintain-windows-runner.ps1 +""" + require( + find_workflow_editor_mutations( + {"repeated-maintenance.yml": repeated_maintenance_fixture} + ) + == [ + "repeated-maintenance.yml: " + "maintain-windows-runner.ps1 mutation surface appears more than once" + ], + "one safe maintenance call must not mask a second unsafe call", + ) + null_detect_only_fixture = """steps: + - name: Unsafe null switch + run: | + ./scripts/unity/bootstrap-windows-runner.ps1 -DetectOnly + Invoke-WindowsRunnerBootstrap -DetectOnly:$null +""" + require( + find_workflow_editor_mutations( + {"null-detect-only.yml": null_detect_only_fixture} + ) + == [ + "null-detect-only.yml: " + "bootstrap-windows-runner.ps1 call is not detect-only" + ], + "a null DetectOnly binding must fail", + ) + parser_bypass_fixtures = { + "block-header-comment": ( + "run: | # valid YAML comment\n unity install 6000.3.16f1", + "direct Unity editor mutation command", + ), + "block-indent-indicator": ( + "run: |2\n unity install 6000.3.16f1", + "direct Unity editor mutation command", + ), + "quoted-run-key": ( + '"run": |\n unity install 6000.3.16f1', + "direct Unity editor mutation command", + ), + "spaced-run-key": ( + "run : |\n unity install 6000.3.16f1", + "direct Unity editor mutation command", + ), + "resolved-unity-command": ( + "run: '& (Get-Command unity).Source install 6000.3.16f1'", + "resolved Unity editor mutation command", + ), + "block-comment-evidence": ( + "run: |\n <#\n -DetectOnly\n #>\n" + " ./scripts/unity/maintain-windows-runner.ps1", + "maintain-windows-runner.ps1 call is not detect-only", + ), + "here-string-evidence": ( + "run: |\n $text = @'\n -DetectOnly\n '@\n" + " ./scripts/unity/maintain-windows-runner.ps1", + "maintain-windows-runner.ps1 call is not detect-only", + ), + "unused-splat": ( + "run: |\n" + " . ./scripts/unity/maintain-windows-runner.ps1\n" + " $maintenanceArgs = @{ DetectOnly = $true }\n" + " Invoke-WindowsRunnerMaintenance", + "maintain-windows-runner.ps1 call is not detect-only", + ), + "nested-detect-only": ( + "run: |\n" + " . ./scripts/unity/maintain-windows-runner.ps1\n" + " $maintenanceArgs = @{\n" + " UnityVersions = @(@{ DetectOnly = $true })\n" + " }\n" + " Invoke-WindowsRunnerMaintenance @maintenanceArgs", + "maintain-windows-runner.ps1 call is not detect-only", + ), + "false-detect-expression": ( + "run: |\n" + " . ./scripts/unity/maintain-windows-runner.ps1\n" + " $maintenanceArgs = @{\n" + " DetectOnly = $true -and $false\n" + " }\n" + " Invoke-WindowsRunnerMaintenance @maintenanceArgs", + "maintain-windows-runner.ps1 call is not detect-only", + ), + "diagnostic-ensure-guards": ( + "run: |\n" + " Write-Host \"Expected -CiManagedOnly " + "-RequireHealthyExisting -ProvisioningProfile " + "with RUNNER_TOOL_CACHE 'u6-v3'\"\n" + " ./scripts/unity/ensure-editor.ps1 " + "-UnityVersion 6000.3.16f1", + "ensure-editor call missing positive -RequireHealthyExisting", + ), + "mutated-splat-property": ( + "run: |\n" + " . ./scripts/unity/maintain-windows-runner.ps1\n" + " $maintenanceArgs = @{ DetectOnly = $true }\n" + " $maintenanceArgs.DetectOnly = $false\n" + " Invoke-WindowsRunnerMaintenance @maintenanceArgs", + "maintain-windows-runner.ps1 call is not detect-only", + ), + "mutated-splat-index": ( + "run: |\n" + " . ./scripts/unity/maintain-windows-runner.ps1\n" + " $maintenanceArgs = @{ DetectOnly = $true }\n" + " $maintenanceArgs['DetectOnly'] = $false\n" + " Invoke-WindowsRunnerMaintenance @maintenanceArgs", + "maintain-windows-runner.ps1 call is not detect-only", + ), + "removed-splat-key": ( + "run: |\n" + " . ./scripts/unity/maintain-windows-runner.ps1\n" + " $maintenanceArgs = @{\n" + " DetectOnly = $true\n" + " }\n" + " [void]$maintenanceArgs.Remove('DetectOnly')\n" + " Invoke-WindowsRunnerMaintenance @maintenanceArgs", + "Invoke-WindowsRunnerMaintenance invocation is not detect-only", + ), + "aliased-splat-mutation": ( + "run: |\n" + " . ./scripts/unity/maintain-windows-runner.ps1\n" + " $maintenanceArgs = @{\n" + " DetectOnly = $true\n" + " }\n" + " $alias = $maintenanceArgs\n" + " $alias.DetectOnly = $false\n" + " Invoke-WindowsRunnerMaintenance @maintenanceArgs", + "maintain-windows-runner.ps1 call is not detect-only", + ), + "repeated-file-dispatch": ( + "run: |\n" + " $script = Join-Path scripts unity/maintain-windows-runner.ps1\n" + " pwsh -File $script -DetectOnly\n" + " pwsh -File $script", + "assigned command is invoked more than once", + ), + } + for name, (run_yaml, expected_fragment) in parser_bypass_fixtures.items(): + fixture = f"steps:\n - name: unsafe\n {run_yaml}\n" + require( + any( + expected_fragment in violation + for violation in find_workflow_editor_mutations( + {f"{name}.yml": fixture} + ) + ), + f"{name} parser bypass fixture must fail", + ) + yaml_and_binding_fixtures = { + "flow-step": ( + 'steps:\n - { name: unsafe, run: "unity install 6000.3.16f1" }\n', + "direct Unity editor mutation command", + ), + "false-direct-switch": ( + "steps:\n - name: unsafe\n run: |\n" + " . ./scripts/unity/maintain-windows-runner.ps1\n" + " Invoke-WindowsRunnerMaintenance " + "-DetectOnly:$true.Equals($false)\n", + "maintain-windows-runner.ps1 call is not detect-only", + ), + "module-invoke-expression": ( + "steps:\n - name: unsafe\n run: |\n" + " Microsoft.PowerShell.Utility\\Invoke-Expression " + "'unity install 6000.3.16f1'\n", + "Invoke-Expression is forbidden", + ), + "invoke-expression-alias": ( + "steps:\n - name: unsafe\n run: |\n" + " iex 'unity install 6000.3.16f1'\n", + "Invoke-Expression is forbidden", + ), + "parameterized-get-command": ( + "steps:\n - name: unsafe\n run: |\n" + " & (Get-Command -Name unity).Source install 6000.3.16f1\n", + "resolved Unity editor mutation command", + ), + "variable-install-verb": ( + "steps:\n - name: unsafe\n run: |\n" + " $verb='install'\n" + " & unity $verb 6000.3.16f1\n", + "direct Unity editor mutation command", + ), + "two-variable-indirection": ( + "steps:\n - name: unsafe\n run: |\n" + " $cli='unity'\n" + " $verb='install'\n" + " & $cli $verb 6000.3.16f1\n", + "variable command invocation is not an approved detect-only runner audit", + ), + "nested-powershell-command": ( + "steps:\n - name: unsafe\n" + ' run: powershell -Command "unity install 6000.3.16f1"\n', + "nested Unity editor mutation command", + ), + "nested-start-process": ( + "steps:\n - name: unsafe\n run: |\n" + " Start-Process powershell -ArgumentList " + "'-Command', 'unity install 6000.3.16f1' -Wait\n", + "nested Unity editor mutation process", + ), + "braced-variable-command": ( + "steps:\n - name: unsafe\n run: |\n" + " $unity='unity'\n" + " & ${unity} install 6000.3.16f1\n", + "direct Unity editor mutation command", + ), + "script-block-command": ( + "steps:\n - name: unsafe\n run: |\n" + " Start-Job { unity install 6000.3.16f1 } " + "| Wait-Job | Receive-Job\n", + "direct Unity editor mutation command", + ), + "dot-variable-command": ( + "steps:\n - name: unsafe\n run: |\n" + " $cli='unity'\n" + " $verb='install'\n" + " . $cli $verb 6000.3.16f1\n", + "variable command invocation is not an approved detect-only runner audit", + ), + "aliased-run": ( + "x-command: &unsafe |\n" + " unity install 6000.3.16f1\n" + "steps:\n" + " - name: unsafe\n" + " run: *unsafe\n", + "direct Unity editor mutation command", + ), + "start-process-alias": ( + "steps:\n - name: unsafe\n run: |\n" + " $cli='unity'\n" + " $verb='install'\n" + " saps $cli -ArgumentList $verb,'6000.3.16f1' -Wait\n", + "variable command invocation is not an approved detect-only runner audit", + ), + "nested-variable-command": ( + "steps:\n - name: unsafe\n run: |\n" + " $cli='unity'\n" + " $verb='install'\n" + " 1 | ForEach-Object { & $cli $verb 6000.3.16f1 }\n", + "variable command invocation is not an approved detect-only runner audit", + ), + "resolved-variable-command": ( + "steps:\n - name: unsafe\n run: |\n" + " $cli='unity'\n" + " $verb='install'\n" + " & (Get-Command $cli) $verb 6000.3.16f1\n", + "dynamic resolved-command invocation is forbidden", + ), + "resolved-start-process": ( + "steps:\n - name: unsafe\n run: |\n" + " $cli='unity'\n" + " $verb='install'\n" + " Start-Process (Get-Command $cli).Source " + "-ArgumentList $verb,'6000.3.16f1' -Wait\n", + "dynamic resolved-command invocation is forbidden", + ), + "unbound-canonical-root": ( + "steps:\n - name: unsafe\n run: |\n" + " Write-Host $env:RUNNER_TOOL_CACHE 'u6-v3'\n" + " ./scripts/unity/ensure-editor.ps1 " + "-InstallRoot 'C:\\Other' -CiManagedOnly " + "-ProvisioningProfile EditorOnly -RequireHealthyExisting\n", + "ensure-editor call missing canonical " + "RUNNER_TOOL_CACHE/u6-v3 -InstallRoot", + ), + } + for name, (fixture, expected_fragment) in yaml_and_binding_fixtures.items(): + require( + any( + expected_fragment in violation + for violation in find_workflow_editor_mutations( + {f"{name}.yml": fixture} + ) + ), + f"{name} YAML or binding bypass fixture must fail", + ) + commented_maintenance_fixture = """steps: + - name: Maintain runner + run: ./scripts/unity/maintain-windows-runner.ps1 # -DetectOnly +""" + require( + find_workflow_editor_mutations( + {"commented-maintenance.yml": commented_maintenance_fixture} + ) + == [ + "commented-maintenance.yml: " + "maintain-windows-runner.ps1 call is not detect-only" + ], + "inline comments must not make runner maintenance detect-only", + ) + maintain_source = Path("scripts/unity/maintain-windows-runner.ps1").read_text( + encoding="utf-8" + ) + require( + "$busyProcesses = @($busyProcesses | Where-Object { $_.name -ne 'Runner.Worker' })" + in maintain_source, + "detect-only runner audit must ignore its own Runner.Worker", + ) + require( + "& $bootstrap -DetectOnly -UnityInstallRoot $InstallRoot" + in maintain_source, + "runner audit must validate host prerequisites against the canonical editor root", + ) + prereq_action_source = Path( + ".github/actions/assert-unity-host-prereqs/action.yml" + ).read_text(encoding="utf-8") + require( + "& $scriptPath -DetectOnly -UnityInstallRoot $unityInstallRoot" + in prereq_action_source + and "Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3'" + in prereq_action_source, + "per-job host prerequisite checks must be detect-only at the canonical editor root", + ) + runner_audit_source = Path(".github/workflows/runner-bootstrap.yml").read_text( + encoding="utf-8" + ) + require( + "DetectOnly = $true" in runner_audit_source + and "Join-Path $env:RUNNER_TOOL_CACHE 'u6-v3'" in runner_audit_source, + "runner audit workflow must be validation-only at the canonical editor root", + ) + ensure_editor_source = Path("scripts/unity/ensure-editor.ps1").read_text( + encoding="utf-8" + ) + require( + "$RequireHealthyExisting -and $CiManagedOnly" in ensure_editor_source + and '"$UnityVersion\\Editor\\Unity.exe"' in ensure_editor_source, + "healthy-existing CI validation must require the central return action's canonical editor leaf", + ) for workflow, job_id in LICENSED_LOCK_WINDOWS: validate_lock_window_timeout_budget( job_block(workflow.read_text(encoding="utf-8"), job_id), From 98fc5551ae1224b20ffb07dd60ddc77c090fc26d Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 30 Jul 2026 22:04:29 +0000 Subject: [PATCH 22/22] docs(progress): record validation-only correction --- progress/session-178-unity-validation-only.md | 106 ++++++++++++++++++ .../session-178-unity-validation-only.md.meta | 7 ++ 2 files changed, 113 insertions(+) create mode 100644 progress/session-178-unity-validation-only.md create mode 100644 progress/session-178-unity-validation-only.md.meta diff --git a/progress/session-178-unity-validation-only.md b/progress/session-178-unity-validation-only.md new file mode 100644 index 00000000..a4c7a302 --- /dev/null +++ b/progress/session-178-unity-validation-only.md @@ -0,0 +1,106 @@ +# Session 178 - Unity workflow validation-only correction + +Date: 2026-07-30 +Branch: `codex/issue-305-enrollment-remediation` +PR: **#316** + +## Outcome + +Unity editor and module installation now has a strict host/CI boundary: + +- a runner administrator installs and repairs editors and modules directly on + the Windows host under `RUNNER_TOOL_CACHE/u6-v3`; +- every workflow only validates the exact existing editor and required profile + with `-CiManagedOnly -RequireHealthyExisting`; +- validation runs before the organization lock and fails with manual + administrator remediation when an editor, module, or host prerequisite is + absent; +- Actions never installs, repairs, uninstalls, or quarantines an editor and does + not require administrator credentials. + +The correction covers all five licensed lock windows in `unity-tests.yml`, +`unity-benchmarks.yml`, `perf-numbers.yml`, and both release jobs. The runner +audit and the host-prerequisite action are also detection-only. Manual +maintenance remains available through `maintain-windows-runner.ps1` and +`bootstrap-windows-runner.ps1` when an administrator runs them directly on the +host. + +## Root cause and red-green evidence + +The previous design reused the manual editor maintenance path from Actions. That +was the wrong ownership boundary: provisioning requires host administrator +access, while a workflow must be able to run without those credentials. It also +made a missing module look like an in-workflow repair problem and exposed the +shared editor tree to long-running mutation. + +The focused workflow contracts failed after the workflows were changed to +validation-only because they still required provisioning. The contracts were +then rewritten to require literal validation-only switches, the canonical +managed root, and a pre-lock position. The Python policy validator also rejects +workflow attempts to reach install or repair behavior through YAML or +PowerShell indirection. + +Local verification on commit `3f28a5e4`: + +- full Node suite: 406 passed, 0 failed; +- focused workflow contracts: 17 passed, 0 failed; +- `npm run validate:all`, strict docs, spelling, Actionlint, Yamllint, + PowerShell parsing, pre-commit, and `git diff --check`: passed; +- JavaScript LOC: 17,498 of 17,500; +- two adversarial repository audits: zero findings; +- full Unity Editor assembly on the prior runtime-equivalent head: 549 passed, + 0 failed. The correction changes workflows, scripts, tests, and documentation, + not package C#. + +## Live PR state + +PR #316 has one current head, `3f28a5e4`. Static CI and Cursor Bugbot are green, +and all 10 review threads are resolved. Eight Unity matrix cells pass. The final +cell, Unity `6000.3.16f1` standalone on `DAD-MACHINE`, fails validation before +the organization lock because the existing editor lacks the +`windows-il2cpp` module. + +That failure is the intended validation-only behavior. An administrator must +repair the editor directly on `DAD-MACHINE`. A direct module-add attempt +confirmed that this editor is not Hub/CLI-managed: + +```text +Error: No modules found for this editor. +Module installation is only supported for editors installed with Unity Hub. +``` + +The administrator must therefore run the repository's bounded reinstall path +from an elevated host shell: + +```powershell +$editorRoot = 'E:\actions-runner\_tool\u6-v3' +.\scripts\unity\maintain-windows-runner.ps1 ` + -UnityVersions @('6000.3.16f1') ` + -InstallRoot $editorRoot ` + -ProvisioningProfile StandaloneWindowsIl2Cpp ` + -Force +``` + +The script detects the unmanageable editor, tries an atomic `install -m` +reinstallation, verifies the module on disk, and falls back to a bounded +quarantine plus reinstall when necessary. No workflow should execute that +repair. After the host is repaired, rerun the failed Unity job, merge only when +every required check is green, and run the central organization audit against +the exact merged default-branch commit. + +The latest trusted central audit completed against current `master` commit +`645cde0551e92ed2fe4fc8cc128dda807ec348ba`. Its sanitized artifact reports the +same 56 DxMessaging findings across the six paid-serial jobs, so issue #305 is +not cleared by repository-side tests alone. The post-merge audit must report +zero findings at the exact merged commit. + +## Remaining delivery steps + +- Install `windows-il2cpp` manually on `DAD-MACHINE`. +- Rerun Unity CI and require `Unity CI Success`. +- Reconfirm zero unresolved review threads and merge PR #316. +- Run the trusted central enrollment audit against the merged commit and + require zero DxMessaging findings. +- Reduce the default-branch ruleset to the two aggregate contexts, `CI Success` + and `Unity CI Success`, while preserving its existing conditions and bypass + actor. diff --git a/progress/session-178-unity-validation-only.md.meta b/progress/session-178-unity-validation-only.md.meta new file mode 100644 index 00000000..a9c0950f --- /dev/null +++ b/progress/session-178-unity-validation-only.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3e4e7dc89faea1eb7791bc93cc1abd97 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: