diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6adab2efa1..287a2e7a92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: pull_request: branches: [main] - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] merge_group: branches: [main] types: [checks_requested] @@ -20,12 +20,15 @@ concurrency: jobs: changes: name: Detect Build Scope + if: github.event_name != 'pull_request' || github.event.action != 'closed' runs-on: ubuntu-latest permissions: contents: read pull-requests: read outputs: compile: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'merge_group') && 'true' || steps.filter.outputs.compile }} + fast: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'merge_group') && 'true' || steps.filter.outputs.fast }} + lua: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'merge_group') && 'true' || steps.filter.outputs.lua }} steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -49,10 +52,43 @@ jobs: - "CMakeLists.txt" - "CMakePresets.json" - ".github/workflows/ci.yml" - - ".github/workflows/reusable-build-windows.yml" + - ".github/workflows/reusable-build-linux.yml" + fast: + - "src/**" + - "tests/**" + - "modules/**" + - "mods/**" + - "data/**" + - "cmake/**" + - "vc18/**" + - "vcpkg.json" + - "vcpkg-configuration.json" + - "CMakeLists.txt" + - "CMakePresets.json" + - ".github/**" + - ".yamllint.yaml" + - "**/*.lua" + - "**/*.xml" + lua: + - "src/**" + - "tests/**" + - "modules/**" + - "mods/**" + - "data/**" + - "cmake/**" + - "vc18/**" + - "vcpkg.json" + - "vcpkg-configuration.json" + - "CMakeLists.txt" + - "CMakePresets.json" + - ".github/workflows/ci.yml" + - ".github/workflows/reusable-build-linux.yml" + - ".github/workflows/reusable-tests-lua.yml" checks: name: Fast Checks + needs: changes + if: needs.changes.outputs.fast == 'true' permissions: contents: read checks: write @@ -61,27 +97,117 @@ jobs: tests-lua: name: Lua Syntax + needs: changes + if: needs.changes.outputs.lua == 'true' permissions: contents: read uses: ./.github/workflows/reusable-tests-lua.yml - build-windows: - name: Build - Windows + build-linux: + name: Build - Linux needs: [changes, checks, tests-lua] if: needs.changes.outputs.compile == 'true' && needs.checks.result == 'success' && needs.tests-lua.result == 'success' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) permissions: contents: read packages: read - uses: ./.github/workflows/reusable-build-windows.yml + uses: ./.github/workflows/reusable-build-linux.yml + + smoke-linux: + name: Client Startup Smoke - Linux + needs: [changes, build-linux] + if: needs.changes.outputs.compile == 'true' && needs.build-linux.result == 'success' && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: read + steps: + - name: Checkout repository resources + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Download Linux release artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: linux-linux-release + path: build/linux-release/bin + + - name: Install headless runtime dependencies + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y \ + libgl1 \ + libgl1-mesa-dri \ + libglu1-mesa \ + libopenal1 \ + libx11-6 \ + libxcursor1 \ + libxi6 \ + libxinerama1 \ + libxrandr2 \ + xauth \ + xvfb + + - name: Verify client artifact dependencies + shell: bash + run: | + set -euo pipefail + client="build/linux-release/bin/otclient" + test -f "${client}" + chmod +x "${client}" + if ldd "${client}" | tee "${RUNNER_TEMP}/otclient-ldd.log" | grep -q "not found"; then + echo "::error title=Missing runtime dependency::Linux client artifact has unresolved shared libraries." + exit 1 + fi + + - name: Launch client under virtual display + env: + ALSOFT_DRIVERS: "null" + LIBGL_ALWAYS_SOFTWARE: "1" + shell: bash + run: | + set -euo pipefail + client="./build/linux-release/bin/otclient" + user_dir="${RUNNER_TEMP}/otclient-smoke-user" + log="${RUNNER_TEMP}/otclient-smoke.log" + mkdir -p "${user_dir}" + + set +e + timeout --signal=TERM --kill-after=5s 20s \ + xvfb-run -a -s "-screen 0 1280x720x24" \ + "${client}" --user-dir="${user_dir}" >"${log}" 2>&1 + status=$? + set -e + + cat "${log}" + if [[ "${status}" -ne 124 ]]; then + echo "::error title=Client startup smoke failed::Expected OTClient to stay alive until the 20s smoke timeout; exit status was ${status}." + exit 1 + fi + + echo "OTClient remained alive for the bounded 20s headless startup window." + + - name: Upload startup smoke evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-client-startup-smoke + path: | + ${{ runner.temp }}/otclient-smoke.log + ${{ runner.temp }}/otclient-ldd.log + if-no-files-found: warn required: name: CI / Required - if: always() + if: always() && (github.event_name != 'pull_request' || github.event.action != 'closed') needs: - changes - checks - tests-lua - - build-windows + - build-linux + - smoke-linux runs-on: ubuntu-latest permissions: {} steps: @@ -100,31 +226,62 @@ jobs: jobs = json.loads(os.environ["NEEDS_JSON"]) rejected = {} - for name in ("changes", "checks", "tests-lua"): - result = jobs[name].get("result") - if result != "success": - rejected[name] = f"expected success, got {result}" + changes_result = jobs["changes"].get("result") + if changes_result != "success": + rejected["changes"] = f"expected success, got {changes_result}" + + outputs = jobs["changes"].get("outputs", {}) + scopes = { + "compile": outputs.get("compile"), + "fast": outputs.get("fast"), + "lua": outputs.get("lua"), + } + for scope, value in scopes.items(): + if value not in {"true", "false"}: + rejected[f"scope:{scope}"] = ( + f"expected true or false, got {value!r}" + ) + + for job_name, scope_name in (("checks", "fast"), ("tests-lua", "lua")): + result = jobs[job_name].get("result") + scope_value = scopes[scope_name] + if scope_value == "true" and result != "success": + rejected[job_name] = ( + f"scope {scope_name}=true requires success, got {result}" + ) + elif scope_value == "false" and result != "skipped": + rejected[job_name] = ( + f"scope {scope_name}=false requires skipped, got {result}" + ) - compile_scope = jobs["changes"].get("outputs", {}).get("compile") - if compile_scope not in {"true", "false"}: - rejected["scope:compile"] = ( - f"expected true or false, got {compile_scope!r}" - ) + compile_scope = scopes["compile"] + if compile_scope == "true": + for required_scope in ("fast", "lua"): + if scopes[required_scope] != "true": + rejected[f"scope:{required_scope}"] = ( + f"compile=true requires {required_scope}=true, got {scopes[required_scope]!r}" + ) is_draft = os.environ["IS_DRAFT"] == "true" - windows_result = jobs["build-windows"].get("result") - if windows_result not in {"success", "skipped"}: - rejected["build-windows"] = ( - f"unexpected conclusion {windows_result}" - ) - elif not is_draft and compile_scope == "true" and windows_result != "success": - rejected["build-windows"] = ( - f"scope compile=true requires success, got {windows_result}" - ) + for job_name in ("build-linux", "smoke-linux"): + result = jobs[job_name].get("result") + if result not in {"success", "skipped"}: + rejected[job_name] = f"unexpected conclusion {result}" + elif not is_draft and compile_scope == "true" and result != "success": + rejected[job_name] = ( + f"scope compile=true requires success, got {result}" + ) + elif (is_draft or compile_scope == "false") and result != "skipped": + rejected[job_name] = ( + f"draft or compile=false requires skipped, got {result}" + ) print("Required job results:") for name in sorted(jobs): print(f"- {name}: {jobs[name].get('result')}") + print("Detected scopes:") + for name in sorted(scopes): + print(f"- {name}: {scopes[name]}") if rejected: for name, reason in sorted(rejected.items()): diff --git a/.github/workflows/infrastructure-retry.yml b/.github/workflows/infrastructure-retry.yml index b5f79b6d39..0182745d6c 100644 --- a/.github/workflows/infrastructure-retry.yml +++ b/.github/workflows/infrastructure-retry.yml @@ -11,12 +11,12 @@ permissions: {} jobs: retry-once: name: Retry infrastructure failure once - # GitHub aggregates an exact job-level startup_failure as workflow failure; - # the script below proves that classification before issuing a retry. + # Superseded runs are intentionally cancelled by CI concurrency and must not + # create more runner demand. Only a real timeout or a proven startup failure + # is eligible for one automatic retry. if: >- github.event.workflow_run.run_attempt == 1 && - (github.event.workflow_run.conclusion == 'cancelled' || - github.event.workflow_run.conclusion == 'timed_out' || + (github.event.workflow_run.conclusion == 'timed_out' || github.event.workflow_run.conclusion == 'failure') runs-on: ubuntu-latest timeout-minutes: 5 @@ -28,37 +28,19 @@ jobs: env: CONCLUSION: ${{ github.event.workflow_run.conclusion }} GH_TOKEN: ${{ github.token }} - HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} REPOSITORY: ${{ github.repository }} RUN_ID: ${{ github.event.workflow_run.id }} - RUN_NUMBER: ${{ github.event.workflow_run.run_number }} - WORKFLOW_EVENT: ${{ github.event.workflow_run.event }} - WORKFLOW_ID: ${{ github.event.workflow_run.workflow_id }} shell: bash run: | set -euo pipefail - if [[ ! "${RUN_ID}" =~ ^[0-9]+$ || ! "${RUN_NUMBER}" =~ ^[0-9]+$ || ! "${WORKFLOW_ID}" =~ ^[0-9]+$ ]]; then + if [[ ! "${RUN_ID}" =~ ^[0-9]+$ ]]; then echo "::error title=Invalid workflow run metadata::Refusing to issue a retry request." exit 1 fi retry_reason="" - if [[ "${CONCLUSION}" == "cancelled" ]]; then - newer_runs="$( - gh api --method GET \ - "repos/${REPOSITORY}/actions/workflows/${WORKFLOW_ID}/runs" \ - -f branch="${HEAD_BRANCH}" \ - -f event="${WORKFLOW_EVENT}" \ - -F per_page=100 \ - --jq ".workflow_runs | map(select(.run_number > ${RUN_NUMBER})) | length" - )" - if (( newer_runs > 0 )); then - echo "::notice title=Stale cancellation is not retried::A newer CI run already exists for this branch and event." - exit 0 - fi - retry_reason="workflow was cancelled and has not been superseded" - elif [[ "${CONCLUSION}" == "timed_out" ]]; then + if [[ "${CONCLUSION}" == "timed_out" ]]; then # Repository policy intentionally permits one retry for the exact timed_out conclusion. retry_reason="workflow timed out" elif [[ "${CONCLUSION}" == "failure" ]]; then diff --git a/.github/workflows/reusable-build-windows.yml b/.github/workflows/reusable-build-windows.yml deleted file mode 100644 index bfb1912739..0000000000 --- a/.github/workflows/reusable-build-windows.yml +++ /dev/null @@ -1,208 +0,0 @@ -name: Reusable Windows Build - -on: - workflow_call: - -permissions: - contents: read - packages: read - -jobs: - build: - name: Compile (${{ matrix.name }}) - strategy: - fail-fast: false - matrix: - include: - - name: CMake Release - type: CMake - preset: windows-release - run_tests: false - upload: true - artifact: windows-cmake-release - - name: CMake Tests - type: CMake - preset: windows-tests - run_tests: true - upload: false - artifact: windows-cmake-tests - - name: Solution Debug - type: Solution - configuration: Debug - artifact: windows-solution-debug - - name: Solution OpenGL - type: Solution - configuration: OpenGL - artifact: windows-solution-opengl - - name: Solution DirectX - type: Solution - configuration: DirectX - artifact: windows-solution-directx - runs-on: windows-2025 - env: - VCPKG_BINARY_CACHE_ACCESS: read - VCPKG_BINARY_SOURCES: "clear;nuget,https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json,read;nugettimeout,600" - VCPKG_NUGET_REPOSITORY: https://github.com/${{ github.repository }}.git - VCPKG_NUGET_API_KEY: ${{ github.token }} - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - SCCACHE_DIR: ${{ github.workspace }}\.sccache - steps: - - name: Install VS 2026 Build Tools (v145 toolset) - if: matrix.type == 'Solution' - shell: pwsh - run: | - $acceptedExitCodes = @(0, 3010) - for ($attempt = 1; $attempt -le 3; $attempt++) { - choco install visualstudio2026-workload-vctools --yes --ignore-package-exit-codes=3010 --no-progress - if ($acceptedExitCodes -contains $LASTEXITCODE) { - exit 0 - } - - Write-Warning "Chocolatey install failed with exit code $LASTEXITCODE on attempt $attempt." - if ($attempt -lt 3) { - Start-Sleep -Seconds (30 * $attempt) - } - } - - Write-Warning "Chocolatey could not install the VS 2026 v145 workload. Continuing so the next step can use any runner-provided toolset." - - - name: Setup MSBuild.exe - if: matrix.type == 'Solution' - uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3.0.0 - with: - vs-prerelease: true - vs-version: "latest" - - - name: Verify v145 platform toolset - if: matrix.type == 'Solution' - shell: pwsh - run: | - $programFilesX86 = [Environment]::GetFolderPath("ProgramFilesX86") - $vswhere = Join-Path $programFilesX86 "Microsoft Visual Studio\Installer\vswhere.exe" - if (-not (Test-Path $vswhere)) { - throw "vswhere.exe not found" - } - - $installPath = & $vswhere -latest -prerelease -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath - if (-not $installPath) { - throw "Visual Studio with VC tools was not found" - } - - $vcMsbuildRoot = Join-Path $installPath "MSBuild\Microsoft\VC" - $toolset = Get-ChildItem -Path $vcMsbuildRoot -Directory -Recurse -Filter v145 -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -match "\\PlatformToolsets\\v145$" } | - Select-Object -First 1 - if (-not $toolset) { - Get-ChildItem -Path $vcMsbuildRoot -Directory -Recurse -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -match "\\PlatformToolsets\\v\d+$" } | - Sort-Object FullName | - ForEach-Object { Write-Host "Found platform toolset: $($_.FullName)" } - throw "v145 platform toolset was not found under $vcMsbuildRoot" - } - - Write-Host "Using v145 platform toolset at $($toolset.FullName)" - - - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - persist-credentials: false - - - name: Get vcpkg baseline - shell: pwsh - run: | - $json = Get-Content vcpkg.json -Raw | ConvertFrom-Json - $vcpkgCommitId = $json.'builtin-baseline' - "VCPKG_GIT_COMMIT_ID=$vcpkgCommitId" | Out-File -FilePath $env:GITHUB_ENV -Append - - - name: Remove Windows pre-installed MySQL - if: matrix.type == 'CMake' - shell: pwsh - run: Remove-Item -Recurse -Force C:\mysql* -ErrorAction SilentlyContinue - - - name: Prepare sccache directory - if: matrix.type == 'CMake' - shell: pwsh - run: New-Item -ItemType Directory -Force -Path $env:SCCACHE_DIR | Out-Null - - - name: CCache - if: matrix.type == 'CMake' - uses: hendrikmuhs/ccache-action@d62db5f07c26379fc4b4e0916f098a92573c3b03 # v1.2.23 - with: - max-size: "1G" - variant: "sccache" - key: ccache-windows-${{ matrix.preset }} - restore-keys: | - ccache-windows - - - name: Setup NuGet source for GitHub Packages - shell: pwsh - env: - NUGET_AUTH_TOKEN: ${{ env.VCPKG_NUGET_API_KEY }} - run: | - $feed = "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" - $nuget = (Get-Command nuget.exe -ErrorAction SilentlyContinue).Source - if (-not $nuget) { - throw "nuget.exe not found in PATH" - } - - & $nuget sources remove -Name "GitHubPackages" 2>&1 | Out-Null - & $nuget sources add ` - -Name "GitHubPackages" ` - -Source $feed ` - -UserName "${{ github.repository_owner }}" ` - -Password "$env:NUGET_AUTH_TOKEN" ` - -StorePasswordInClearText - - & $nuget setapikey "$env:NUGET_AUTH_TOKEN" -Source $feed - & $nuget sources list - - - name: Setup vcpkg - uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11.6 - with: - vcpkgDirectory: ${{ matrix.type == 'Solution' && format('{0}/vcpkg', github.workspace) || '' }} - vcpkgGitURL: "https://github.com/microsoft/vcpkg.git" - vcpkgGitCommitId: ${{ env.VCPKG_GIT_COMMIT_ID }} - - - name: Run CMake - if: matrix.type == 'CMake' - uses: lukka/run-cmake@5d55ea7949e25f69f0ecb516d8d572297e03a956 # v10.9 - with: - configurePreset: ${{ matrix.preset }} - buildPreset: ${{ matrix.preset }} - configurePresetAdditionalArgs: "['-DTOGGLE_BIN_FOLDER=ON', '-DOPTIONS_ENABLE_IPO=OFF']" - - - name: Run CTest - if: matrix.type == 'CMake' && matrix.run_tests - shell: pwsh - run: ctest --preset ${{ matrix.preset }} --parallel 2 - - - name: Upload artifacts (CMake) - if: matrix.type == 'CMake' && matrix.upload - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ${{ matrix.artifact }} - path: build/${{ matrix.preset }}/bin/ - - - name: Build project (MSBuild solution) - if: matrix.type == 'Solution' - shell: pwsh - run: | - $vcpkgRoot = Join-Path (Get-Location) "vcpkg" - $outDir = Join-Path (Get-Location) "artifacts/${{ matrix.configuration }}" - New-Item -ItemType Directory -Force -Path $outDir | Out-Null - - msbuild.exe vc18/otclient.sln ` - /m ` - /p:Configuration=${{ matrix.configuration }} ` - /p:Platform=x64 ` - /p:VcpkgEnableManifest=true ` - /p:VcpkgRoot="$vcpkgRoot" ` - /p:OutDir="$outDir\\" ` - /p:GITHUB_WORKSPACE="${{ github.workspace }}" - - - name: Upload artifacts (Solution) - if: matrix.type == 'Solution' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ${{ matrix.artifact }} - path: artifacts/${{ matrix.configuration }}/ diff --git a/docs/agents/tasks/active/OTC-20260816-linux-ci-hybrid.md b/docs/agents/tasks/active/OTC-20260816-linux-ci-hybrid.md new file mode 100644 index 0000000000..d6dcf38760 --- /dev/null +++ b/docs/agents/tasks/active/OTC-20260816-linux-ci-hybrid.md @@ -0,0 +1,125 @@ +--- +task_id: OTC-20260816-linux-ci-hybrid +status: active +owner: current-agent +branch: ci/OTC-20260816-linux-ci-hybrid +base_branch: main +related_pr: "331" +feature_scope: infrastructure +completion_claim: internal_only +ownership_released: false +owned_paths: + - .github/workflows/ci.yml + - .github/workflows/infrastructure-retry.yml + - .github/workflows/reusable-build-windows.yml + - docs/agents/tasks/active/OTC-20260816-linux-ci-hybrid.md + - docs/agents/tasks/archive/OTC-20260816-linux-ci-hybrid.md +--- + +# OTC-20260816 Linux CI hybrid + +## Objective + +Make the ordinary OTClient build/test path Linux-only on GitHub-hosted runners, including a bounded headless startup smoke of the built Linux client, while preserving Synology/self-hosted capacity exclusively for work that genuinely needs controlled runtime, LAN, real display/input, persistent sessions, or physical gameplay evidence. + +## Coordination + +- Owner explicitly authorized disabling Windows builds because this OTClient deployment uses Linux only. +- Owner explicitly accepted the runner boundary: deterministic/static/build/startup validation on GitHub-hosted runners; physical gameplay control and persistent runtime evidence on Synology. +- PR #328 is closed as superseded; its safe hosted-runner queue reductions are carried forward where applicable without its Windows gate. +- Live validation exposed that PR #328's already-started Windows matrix kept consuming hosted runners after the PR was closed. General CI now listens for `pull_request.closed`; the new no-work run shares the PR concurrency group, so closing a PR cancels its older in-progress CI instead of leaving orphaned build demand. +- PR #280 remains a separate specialized Synology/runtime lane and its owned files are not modified by this task. +- Historical task `OTC-20260712-client-test-foundation` still lists `.github/workflows/reusable-build-linux.yml` as owned although its implementation PR #3 is already merged. This task does not modify that file; the startup smoke is deliberately implemented in `.github/workflows/ci.yml` after the existing Linux build artifact is produced. + +## Implemented scope + +- Replace the required Windows compile job in `.github/workflows/ci.yml` with the existing GitHub-hosted Linux reusable build. +- Keep ordinary scope detection, fast checks, Lua checks, required aggregation, and Linux builds on GitHub-hosted Ubuntu runners. +- Add a required GitHub-hosted Linux startup-smoke job for compile-relevant non-draft changes: + - download the `linux-linux-release` artifact from the same Actions run; + - check dynamic-library resolution with `ldd`; + - run the real `otclient` binary under `Xvfb` with software GL and null OpenAL output; + - isolate persisted state with `--user-dir` under `RUNNER_TEMP`; + - require the client to remain alive for a bounded 20-second startup window; + - upload startup and dependency logs as evidence. +- On PR close, create only a no-work CI run in the same concurrency group so any older build for that PR is cancelled without allocating new build/test runners. +- Avoid retrying intentionally cancelled superseded CI runs. +- Remove the reusable Windows build workflow after verifying that the ordinary CI caller is replaced and no active workflow file in the current workflow inventory names another Windows build entry point. +- Do not change the dedicated Synology/Track A runtime workflows. + +## Runner boundary + +### GitHub-hosted runners + +Responsible for deterministic and disposable validation that does not require a durable game session: + +- static analysis, workflow validation and Lua syntax; +- C++/Lua unit and bounded integration tests; +- Linux release/test compilation; +- Linux client artifact dependency validation; +- bounded headless client startup smoke under a virtual X display. + +### Synology/self-hosted runtime + +Responsible for evidence that depends on the real controlled environment: + +- persistent OTClient session and canonical runtime registration; +- real display/input ownership; +- login and physical gameplay control such as walking/clicking; +- LAN/runtime integration requiring the Synology environment; +- long-lived observations and direct runtime evidence. + +A GitHub headless startup smoke is not evidence of successful physical gameplay and must never replace Synology runtime E2E where that evidence is required. + +## Acceptance inventory + +- [x] `CI` has no `windows-2025`, `build-windows`, or `reusable-build-windows.yml` dependency on the implementation branch. +- [x] Compile-relevant PRs require `Build - Linux` via `.github/workflows/reusable-build-linux.yml`. +- [x] Documentation/task-only changes are scoped so unrelated fast/Lua/build/smoke jobs can be skipped. +- [x] Closed PRs have a concurrency-cancellation path that emits no normal build/test work. +- [ ] Generic CI jobs are observed on GitHub-hosted Ubuntu runners on the exact implementation head. +- [ ] Exact-head Actions proves the real Linux release artifact starts under `Xvfb` and survives the bounded 20-second smoke window. +- [ ] Startup smoke evidence artifact contains dependency/startup logs. +- [x] Dedicated Synology/runtime workflow files are outside this task's changed-file set. +- [x] Runner responsibility boundary is durably recorded and explicitly prevents hosted startup smoke from being treated as physical gameplay E2E. +- [x] Superseded `cancelled` CI runs are not automatically retried. +- [ ] Workflow validation/actionlint and exact-head required CI pass. +- [ ] Related PRs are terminal: #328 closed superseded; PR #331 merged when green; #280 intentionally remains separate if still active. + +## Validation + +1. Inspect the exact branch diff and workflow references. +2. Verify no Windows build dependency remains in general CI. +3. Inspect PR #331 exact-head Actions jobs/runner labels. +4. Require workflow syntax/actionlint, both Linux builds, hosted client startup smoke and `CI / Required` success. +5. Verify the smoke job uses the real release artifact, a virtual display, isolated user directory, bounded liveness and no Synology runner labels. +6. Verify closed-PR events skip normal jobs while sharing the same concurrency key used by the PR's active run. +7. Merge only on the exact validated head. +8. Verify post-merge `main` and its Actions outcome. +9. Archive this task and release ownership after post-merge verification. + +## Runtime E2E + +`SPLIT_BY_ENVIRONMENT`: + +- GitHub-hosted environment outcome required here: real release artifact headless startup smoke. +- Physical gameplay/runtime E2E remains intentionally outside this infrastructure task and belongs to the dedicated Synology/Track A runtime lane. That lane must provide its own direct display/PID/session/gameplay evidence when a task requires it. + +## Context checkpoint + +```yaml +state: PROVEN +phase: validation +base_head: a27b9f3383b0555142b31216672e9f0143d2cd3d +implementation_pr: 331 +superseded_pr: 328 +specialized_runtime_pr: 280 +historical_merged_pr_with_stale_task_claim: 3 +observed_orphaned_run: 31934213173 +changed_paths: + - .github/workflows/ci.yml + - .github/workflows/infrastructure-retry.yml + - .github/workflows/reusable-build-windows.yml (removed) + - docs/agents/tasks/active/OTC-20260816-linux-ci-hybrid.md +next_action: validate the new exact PR head through hosted Linux builds plus real client startup smoke, audit the final diff, then merge and archive if green +```