From d01656a1d8bdada747ff7a26198a42f4d168b6fe Mon Sep 17 00:00:00 2001 From: tbphp Date: Sun, 13 Sep 2026 08:31:56 +0800 Subject: [PATCH] =?UTF-8?q?perf(ci):=20=E8=BF=81=E7=A7=BB=E8=87=AA?= =?UTF-8?q?=E6=89=98=E7=AE=A1=E8=BF=90=E8=A1=8C=E5=99=A8=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=B9=B6=E8=A1=8C=E9=AA=8C=E6=94=B6=20(#634)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(ci): 切换现有自托管运行器并复用本机缓存 * perf(ci): 将主要 Linux 任务迁至 Mac ARM64 运行器 * fix(ci): 使用动态端口执行 Windows 安装验收 * test(cpa): 等待异步处理完成后校验 WebSocket 行为 * perf(ci): 并行数据库测试并隔离容器验收端口 * ci: 为数据库合同测试启用内存盘 * test(cpa): 固定断连日志脱敏测试的执行顺序 * fix(ci): 修复自托管验收中断和架构兼容问题 * fix(ci): 原子准备 Windows 安装验收配置 --- .github/actions/web-ci/action.yml | 4 +- .github/scripts/ci-database-contract.sh | 46 ++++ .github/scripts/ci-windows-service-smoke.ps1 | 27 ++- .github/scripts/release-docker-smoke.sh | 12 +- .../release-windows-installer-smoke.ps1 | 66 ++++-- .github/scripts/windows-smoke-recovery.ps1 | 93 ++++++++ .github/workflows/ci.yml | 90 ++++---- .github/workflows/release.yml | 205 ++++++++++++------ .../access_key_prefix_migration_test.go | 4 + .../custom_access_key_migration_test.go | 4 + internal/storage/db_test.go | 10 + internal/storage/migration_recovery_test.go | 10 + .../storage/migrations/0001_initial_test.go | 13 ++ ...e_observation_fresh_until_internal_test.go | 1 + ...003_remove_observation_fresh_until_test.go | 2 + ...4_usage_stats_group_activity_index_test.go | 2 + .../migrations/0005_proxy_config_test.go | 2 + .../migrations/0006_error_decision_test.go | 2 + .../0007_access_key_lifecycle_test.go | 2 + .../storage/model_cooldown_migration_test.go | 4 + .../price_multiplier_migration_test.go | 5 + .../validation_protocol_migration_test.go | 4 + .../webui/windows_installer_contract_test.go | 46 +++- internal/webui/windows_smoke_recovery_test.go | 156 +++++++++++++ .../webui/workflow_database_shards_test.go | 150 +++++++++++++ internal/webui/workflow_docker_port_test.go | 144 ++++++++++++ internal/webui/workflow_self_hosted_test.go | 77 +++++++ internal/webui/workflow_test.go | 16 +- .../embedded/codex_websocket_log_test.go | 50 ++++- .../embedded/codex_websocket_test.go | 29 ++- 30 files changed, 1115 insertions(+), 161 deletions(-) create mode 100755 .github/scripts/ci-database-contract.sh create mode 100644 .github/scripts/windows-smoke-recovery.ps1 create mode 100644 internal/webui/windows_smoke_recovery_test.go create mode 100644 internal/webui/workflow_database_shards_test.go create mode 100644 internal/webui/workflow_docker_port_test.go create mode 100644 internal/webui/workflow_self_hosted_test.go diff --git a/.github/actions/web-ci/action.yml b/.github/actions/web-ci/action.yml index 41323c2f..9a1bc21a 100644 --- a/.github/actions/web-ci/action.yml +++ b/.github/actions/web-ci/action.yml @@ -17,8 +17,8 @@ runs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.18.0 - cache: pnpm - cache-dependency-path: web/pnpm-lock.yaml + # 自托管 Mac 保留 pnpm store,不再恢复远端缓存。 + package-manager-cache: false - name: Install web dependencies shell: bash diff --git a/.github/scripts/ci-database-contract.sh b/.github/scripts/ci-database-contract.sh new file mode 100755 index 00000000..6f6b64a8 --- /dev/null +++ b/.github/scripts/ci-database-contract.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 两组互补:增量迁移契约单独运行,其余(包括未来新增测试)自动归入另一组。 +migrations='^TestExternal(AccessKeyPrefix|CustomAccessKey|PriceMultiplier|ModelCooldown|ValidationProtocol)MigrationContract$' +run='^TestExternal' +skip='^$' +case "${1:-all}" in + all | postgres) ;; + mysql-migrations) run="${migrations}" ;; + mysql-rest) skip="${migrations}" ;; + mysql) + : "${GPT_LOAD_DATABASE_TEST_DSN:?primary MySQL DSN is required}" + : "${GPT_LOAD_DATABASE_SECONDARY_DSN:?secondary MySQL DSN is required}" + logs="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/mysql-contract.XXXXXX")" + cleanup() { + for pid in $(jobs -pr); do + kill "${pid}" 2>/dev/null || true + done + wait || true + rm -rf "${logs}" + } + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + bash "$0" mysql-migrations >"${logs}/migrations.log" 2>&1 & + migrations_pid=$! + GPT_LOAD_DATABASE_TEST_DSN="${GPT_LOAD_DATABASE_SECONDARY_DSN}" \ + bash "$0" mysql-rest >"${logs}/rest.log" 2>&1 & + rest_pid=$! + result=0 + wait "${migrations_pid}" || result=1 + wait "${rest_pid}" || result=1 + for shard in migrations rest; do + echo "::group::MySQL ${shard}" + cat "${logs}/${shard}.log" + echo '::endgroup::' + done + exit "${result}" + ;; + *) echo "unknown database test shard: $1" >&2; exit 2 ;; +esac + +exec go test -v -count=1 ./internal/storage ./internal/control ./internal/requestlog \ + -run "${run}" -skip "${skip}" diff --git a/.github/scripts/ci-windows-service-smoke.ps1 b/.github/scripts/ci-windows-service-smoke.ps1 index 5410b8d6..69728bbc 100644 --- a/.github/scripts/ci-windows-service-smoke.ps1 +++ b/.github/scripts/ci-windows-service-smoke.ps1 @@ -17,14 +17,23 @@ $dataDir = Join-Path $configDir "data" $envFile = Join-Path $configDir ".env" $dataOwnerMarker = Join-Path $configDir ".service-smoke-owner" -if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) { - throw "refusing pre-existing Windows service: $serviceName" -} -if (Test-Path $installDir) { - throw "refusing pre-existing installation directory: $installDir" -} -if (Test-Path $configDir) { - throw "refusing pre-existing ProgramData directory: $configDir" +. "$PSScriptRoot/windows-smoke-recovery.ps1" +$smokeMutex = Enter-WindowsSmoke -InstallDir $installDir -ConfigDir $configDir + +try { + if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) { + throw "refusing pre-existing Windows service: $serviceName" + } + if (Test-Path $installDir) { + throw "refusing pre-existing installation directory: $installDir" + } + if (Test-Path $configDir) { + throw "refusing pre-existing ProgramData directory: $configDir" + } +} catch { + $smokeMutex.ReleaseMutex() + $smokeMutex.Dispose() + throw } function Assert-ServiceAcl { @@ -131,4 +140,6 @@ try { ((Get-Content $dataOwnerMarker -Raw).Trim() -eq $installOwnerToken)) { Remove-Item -Recurse -Force $configDir -ErrorAction SilentlyContinue } + $smokeMutex.ReleaseMutex() + $smokeMutex.Dispose() } diff --git a/.github/scripts/release-docker-smoke.sh b/.github/scripts/release-docker-smoke.sh index 93695183..2f92df0d 100755 --- a/.github/scripts/release-docker-smoke.sh +++ b/.github/scripts/release-docker-smoke.sh @@ -38,8 +38,7 @@ fake_container="gpt-load-release-fake-${suffix}" fake_alias="fake-upstream" volume="gpt-load-release-smoke-${suffix}" network="gpt-load-release-network-${suffix}" -app_port="${RELEASE_SMOKE_APP_PORT:-39413}" -base_url="http://127.0.0.1:${app_port}" +app_port="${RELEASE_SMOKE_APP_PORT:-0}" task_tmp="$(mktemp -d)" smoke_stage="preflight" @@ -55,6 +54,8 @@ cleanup_temp() { fi } trap cleanup_temp EXIT +trap 'exit 130' INT +trap 'exit 143' TERM for target in "${container}" "${probe}" "${fake_container}"; do if docker container inspect "${target}" >/dev/null 2>&1; then @@ -207,9 +208,14 @@ start_container() { docker run -d \ --name "${container}" \ --network "${network}" \ - --publish "${app_port}:3001" \ + --publish "127.0.0.1:${app_port}:3001" \ --volume "${volume}:/app/data" \ "${image}" >/dev/null + # Docker 原子分配可用端口;重建容器后也重新查询,避免多个 Runner 抢占端口。 + local binding + binding="$(docker port "${container}" 3001/tcp)" + [[ "${binding}" =~ ^127\.0\.0\.1:[0-9]+$ ]] + base_url="http://${binding}" } wait_for_health() { diff --git a/.github/scripts/release-windows-installer-smoke.ps1 b/.github/scripts/release-windows-installer-smoke.ps1 index ced49208..35a384c5 100644 --- a/.github/scripts/release-windows-installer-smoke.ps1 +++ b/.github/scripts/release-windows-installer-smoke.ps1 @@ -28,8 +28,11 @@ $installOwnerToken = $suffix $programData = [Environment]::GetFolderPath("CommonApplicationData") $configDir = Join-Path $programData "GPT-Load" $dataDir = Join-Path $configDir "data" +$envFile = Join-Path $configDir ".env" $dataOwnerMarker = Join-Path $configDir ".installer-smoke-owner" $failureDataMarker = Join-Path $dataDir "installer-smoke-failure.txt" +$preparedConfig = Join-Path $programData ".gpt-load-installer-smoke-$suffix" +$ownsPreparedConfig = $false $upgradeMarker = Join-Path $dataDir "installer-smoke-upgrade.txt" $installedBinary = Join-Path $installDir "gpt-load.exe" $uninstaller = Join-Path $installDir "unins000.exe" @@ -43,19 +46,28 @@ if ([System.IO.Path]::GetFullPath($configDir) -ne [System.IO.Path]::GetFullPath((Join-Path $programData "GPT-Load"))) { throw "refusing unexpected ProgramData cleanup target: $configDir" } -if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) { - throw "refusing pre-existing Windows service: $serviceName" -} -if (Test-Path $installDir) { - throw "refusing pre-existing installation directory: $installDir" -} -if (Test-Path $configDir) { - throw "refusing pre-existing ProgramData directory: $configDir" -} -foreach ($path in @($desktopShortcut, $startMenuShortcut, $uninstallKey)) { - if (Test-Path $path) { - throw "refusing pre-existing installer smoke path: $path" +. "$PSScriptRoot/windows-smoke-recovery.ps1" +$smokeMutex = Enter-WindowsSmoke -InstallDir $installDir -ConfigDir $configDir + +try { + if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) { + throw "refusing pre-existing Windows service: $serviceName" } + if (Test-Path $installDir) { + throw "refusing pre-existing installation directory: $installDir" + } + if (Test-Path $configDir) { + throw "refusing pre-existing ProgramData directory: $configDir" + } + foreach ($path in @($desktopShortcut, $startMenuShortcut, $uninstallKey)) { + if (Test-Path $path) { + throw "refusing pre-existing installer smoke path: $path" + } + } +} catch { + $smokeMutex.ReleaseMutex() + $smokeMutex.Dispose() + throw } function Invoke-CheckedProcess { @@ -112,17 +124,28 @@ function Assert-ServiceAcl { try { [System.IO.File]::WriteAllText($installOwnerMarker, $installOwnerToken) - New-Item -ItemType Directory -Path $configDir | Out-Null - [System.IO.File]::WriteAllText($dataOwnerMarker, $installOwnerToken) - New-Item -ItemType Directory -Path $dataDir | Out-Null - [System.IO.File]::WriteAllText($failureDataMarker, $installOwnerToken) + # 同盘准备完整归属凭据后再公开固定目录,避免中断留下半初始化状态。 + New-Item -ItemType Directory -Path $preparedConfig | Out-Null + $ownsPreparedConfig = $true + [System.IO.File]::WriteAllText((Join-Path $preparedConfig ".installer-smoke-owner"), $installOwnerToken) + New-Item -ItemType Directory -Path (Join-Path $preparedConfig "data") | Out-Null + [System.IO.File]::WriteAllText((Join-Path $preparedConfig "data/installer-smoke-failure.txt"), $installOwnerToken) + [System.IO.Directory]::Move($preparedConfig, $configDir) $listener = [System.Net.Sockets.TcpListener]::new( [System.Net.IPAddress]::Loopback, - 3001 + 0 ) + # 由系统分配可用端口,避开自托管 Windows 的端口排除范围。 + $listener.ExclusiveAddressUse = $true $listener.Start() try { + $port = $listener.LocalEndpoint.Port + # 测试服务读取同一端口;独占监听保持到安装失败回滚验收结束。 + @( + "HOST=127.0.0.1", + "PORT=$port" + ) | Set-Content -Path $envFile -Encoding utf8NoBOM Invoke-CheckedProcess -Path $setup -Arguments @( "/VERYSILENT", "/SUPPRESSMSGBOXES", @@ -179,7 +202,7 @@ try { $health = $null for ($attempt = 0; $attempt -lt 80; $attempt++) { try { - $health = Invoke-RestMethod "http://127.0.0.1:3001/health" + $health = Invoke-RestMethod "http://127.0.0.1:$port/health" break } catch { Start-Sleep -Milliseconds 250 @@ -196,7 +219,7 @@ try { } $authKey = (Get-Content $authFile -Raw).Trim() $headers = @{ Authorization = "Bearer $authKey" } - Invoke-RestMethod "http://127.0.0.1:3001/api/system/info" -Headers $headers | Out-Null + Invoke-RestMethod "http://127.0.0.1:$port/api/system/info" -Headers $headers | Out-Null [System.IO.File]::WriteAllText($upgradeMarker, $suffix) Invoke-CheckedProcess -Path $setup -Arguments @( @@ -227,6 +250,9 @@ try { $afterHash = (Get-FileHash -Algorithm SHA256 $setup).Hash.ToLowerInvariant() if ($afterHash -ne $expectedHash) { throw "Windows setup checksum mismatch after execution" } } finally { + if ($ownsPreparedConfig -and (Test-Path -LiteralPath $preparedConfig)) { + Remove-Item -LiteralPath $preparedConfig -Recurse -Force + } $ownsInstall = (Test-Path $installOwnerMarker) -and ((Get-Content $installOwnerMarker -Raw).Trim() -eq $installOwnerToken) if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) { @@ -257,4 +283,6 @@ try { ((Get-Content $installOwnerMarker -Raw).Trim() -eq $installOwnerToken)) { Remove-Item -Force $installOwnerMarker -ErrorAction SilentlyContinue } + $smokeMutex.ReleaseMutex() + $smokeMutex.Dispose() } diff --git a/.github/scripts/windows-smoke-recovery.ps1 b/.github/scripts/windows-smoke-recovery.ps1 new file mode 100644 index 00000000..dc0e30e7 --- /dev/null +++ b/.github/scripts/windows-smoke-recovery.ps1 @@ -0,0 +1,93 @@ +function Enter-WindowsSmoke { + param([string]$InstallDir, [string]$ConfigDir) + + # 固定服务名只能由一个 smoke 使用;进程退出后系统自动释放互斥锁。 + $mutex = [System.Threading.Mutex]::new($false, "Global\GPTLoad-Windows-Smoke") + $acquired = $false + try { + try { $acquired = $mutex.WaitOne(0) } + catch [System.Threading.AbandonedMutexException] { $acquired = $true } + if (-not $acquired) { throw "another Windows smoke is running" } + + $owners = @() + foreach ($directory in @($InstallDir, $ConfigDir)) { + if (-not (Test-Path -LiteralPath $directory)) { continue } + if ((Get-Item -LiteralPath $directory -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "refusing linked Windows smoke directory: $directory" + } + foreach ($name in @('.service-smoke-owner', '.installer-smoke-owner')) { + $path = Join-Path $directory $name + if (-not (Test-Path -LiteralPath $path)) { continue } + $item = Get-Item -LiteralPath $path -Force + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "refusing invalid Windows smoke marker: $path" + } + $token = (Get-Content -LiteralPath $path -Raw).Trim() + if ($token -notmatch '^[0-9a-fA-F]{32}$') { throw "invalid Windows smoke owner: $path" } + $owners += @{ Name = $name; Token = $token } + } + } + # 无标记时保留现场,让调用方的原有 preflight 拒绝真实安装。 + if ($owners.Count -eq 0) { return $mutex } + $owner = $owners[0] + foreach ($other in $owners) { + if ($other.Name -ne $owner.Name -or $other.Token -ne $owner.Token) { + throw "conflicting Windows smoke owners" + } + } + $installer = $owner.Name -eq '.installer-smoke-owner' + if ($installer) { + # 安装器会删除、重建安装目录;其归属凭据保存在卸载时保留的 ProgramData。 + $proof = Join-Path $ConfigDir 'data/installer-smoke-failure.txt' + if (-not (Test-Path -LiteralPath $proof) -or + ((Get-Content -LiteralPath $proof -Raw).Trim() -ne $owner.Token)) { + throw "missing installer smoke ownership proof" + } + } else { + foreach ($directory in @($InstallDir, $ConfigDir)) { + if ((Test-Path -LiteralPath $directory) -and + -not (Test-Path -LiteralPath (Join-Path $directory $owner.Name))) { + throw "unmarked Windows smoke directory: $directory" + } + } + } + + $binary = Join-Path $InstallDir 'gpt-load.exe' + $service = Get-CimInstance Win32_Service -Filter "Name='gpt-load'" -ErrorAction Stop + if ($null -ne $service) { + if ($service.PathName -ine ('"' + $binary + '" service run') -or + $service.StartName -ine 'NT AUTHORITY\LocalService') { + throw "refusing Windows service with unexpected configuration" + } + $controller = Get-Service -Name 'gpt-load' -ErrorAction Stop + try { + if ($controller.Status -ne 'Stopped') { + $controller.Stop() + $controller.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15)) + } + } finally { $controller.Dispose() } + & sc.exe delete 'gpt-load' | Out-Null + if ($LASTEXITCODE -ne 0) { throw "failed to remove stale Windows smoke service" } + } + + # 只回收已确认归属的固定目标,不扫描或清理其他任务、服务和目录。 + if (Test-Path -LiteralPath $InstallDir) { Remove-Item -LiteralPath $InstallDir -Recurse -Force } + if ($installer) { + $paths = @( + ([IO.Path]::Combine([Environment]::GetFolderPath('CommonDesktopDirectory'), 'GPT-Load.url')), + ([IO.Path]::Combine([Environment]::GetFolderPath('CommonPrograms'), 'GPT-Load/GPT-Load.url')), + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{E5A127DE-2676-4F6C-B763-CF53C6271883}_is1' + ) + foreach ($path in $paths) { + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } + } + } + # 最后删除归属标记所在目录;中途失败时保留下一次恢复所需的证据。 + if (Test-Path -LiteralPath $ConfigDir) { Remove-Item -LiteralPath $ConfigDir -Recurse -Force } + return $mutex + } catch { + if ($acquired) { $mutex.ReleaseMutex() } + $mutex.Dispose() + throw + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c09f4878..768b36c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,8 @@ permissions: # 竖线分隔在任何错误(含网络错误)时回退到下一个源;默认的逗号只在 # 404/410 时回退,proxy.golang.org 瞬时故障会直接让整个 job 失败。 +# 自托管 Runner 保留本机 Go 缓存;代码与编译参数变化由 Go 自动判定失效。 +# 不再下载/上传 setup-go 缓存,也无需 main push 预热。 env: GOPROXY: https://proxy.golang.org|direct @@ -19,7 +21,7 @@ concurrency: jobs: test: - runs-on: ubuntu-24.04 + runs-on: [self-hosted, macOS, ARM64] timeout-minutes: 20 steps: - name: Checkout repository @@ -34,7 +36,7 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - cache: true + cache: false - name: Check Go formatting run: | @@ -62,7 +64,7 @@ jobs: run: git diff --check race-tests: - runs-on: ubuntu-24.04 + runs-on: [self-hosted, macOS, ARM64] timeout-minutes: 20 steps: - name: Checkout repository @@ -70,27 +72,17 @@ jobs: with: persist-credentials: false - - name: Mark race-instrumented Go cache scope - # race-tests、test、database-contract(*) 默认共用同一个基于 go.sum 的 - # setup-go 缓存 key;命中即不保存,race-instrumented GOCACHE 永远无法 - # 写回,每次都要重新编译。加入这个 marker 文件参与 cache-dependency-path - # 哈希,让 race-tests 拥有独立 key 命名空间。 - run: echo race-tests > .go-cache-scope - - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - cache: true - cache-dependency-path: | - go.sum - .go-cache-scope + cache: false - name: Run race-enabled tests run: go test -race -count=1 -timeout=15m . ./internal/... race-cpa: - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 10 steps: - name: Checkout repository @@ -102,8 +94,7 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - cache: true - cache-dependency-path: third_party/cpaembedded/go.sum + cache: false - name: Run embedded CPA bridge race-enabled tests working-directory: third_party/cpaembedded @@ -111,7 +102,7 @@ jobs: database-contract: name: Database contract (${{ matrix.driver }}) - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 15 strategy: fail-fast: false @@ -119,34 +110,48 @@ jobs: matrix: include: - driver: mysql - dsn: mysql://root:root@127.0.0.1:3306/gpt_load?charset=utf8mb4&transaction_isolation=%27READ-COMMITTED%27 + image: mysql:8.4 + data-dir: /var/lib/mysql + port: "3306" + health: mysqladmin ping -h 127.0.0.1 -proot + dsn: mysql://root:root@127.0.0.1:{0}/gpt_load?charset=utf8mb4&transaction_isolation=%27READ-COMMITTED%27 - driver: postgres - dsn: postgres://postgres:postgres@127.0.0.1:5432/gpt_load?sslmode=disable + image: postgres:18 + data-dir: /var/lib/postgresql + port: "5432" + health: pg_isready -U postgres -d gpt_load + dsn: postgres://postgres:postgres@127.0.0.1:{0}/gpt_load?sslmode=disable + # 仅临时合同测试数据库使用内存盘;每个实例按实际使用量占用,最多 2 GiB。 services: - mysql: - image: mysql:8.4 + database: + image: ${{ matrix.image }} + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: gpt_load + POSTGRES_PASSWORD: postgres + POSTGRES_DB: gpt_load + ports: + - ${{ matrix.port }} + options: >- + --tmpfs ${{ matrix.data-dir }}:rw,nosuid,nodev,size=2g + --health-cmd="${{ matrix.health }}" + --health-interval=5s + --health-timeout=10s + --health-retries=20 + # MySQL 两组使用独立实例,避免不同测试数据库争用同名 GET_LOCK。 + secondary-mysql: + image: ${{ matrix.driver == 'mysql' && 'mysql:8.4' || '' }} env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: gpt_load ports: - - 3306:3306 + - 3306 options: >- + --tmpfs /var/lib/mysql:rw,nosuid,nodev,size=2g --health-cmd="mysqladmin ping -h 127.0.0.1 -proot" --health-interval=5s --health-timeout=10s --health-retries=20 - postgres: - image: postgres:18 - env: - POSTGRES_PASSWORD: postgres - POSTGRES_DB: gpt_load - ports: - - 5432:5432 - options: >- - --health-cmd="pg_isready -U postgres -d gpt_load" - --health-interval=5s - --health-timeout=10s - --health-retries=20 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -157,16 +162,16 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - cache: true + cache: false - name: Run external database contract env: - GPT_LOAD_DATABASE_TEST_DSN: ${{ matrix.dsn }} - run: | - go test -v -count=1 ./internal/storage ./internal/control ./internal/requestlog -run '^TestExternal' + GPT_LOAD_DATABASE_TEST_DSN: ${{ format(matrix.dsn, job.services.database.ports[format('{0}', matrix.port)]) }} + GPT_LOAD_DATABASE_SECONDARY_DSN: ${{ matrix.driver == 'mysql' && format(matrix.dsn, job.services.secondary-mysql.ports['3306']) || '' }} + run: .github/scripts/ci-database-contract.sh "${{ matrix.driver }}" windows-encryption-acl: - runs-on: windows-2025 + runs-on: [self-hosted, Windows, X64] timeout-minutes: 15 steps: - name: Checkout repository @@ -178,7 +183,7 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - cache: true + cache: false - name: Test Windows secure file and storage ACLs run: go test -v -count=1 ./internal/platform/securefile ./internal/platform/encryption ./internal/storage @@ -186,6 +191,9 @@ jobs: - name: Test Windows service lifecycle run: go test -v -count=1 . + - name: Test Windows smoke recovery + run: go test -count=1 ./internal/webui -run '^TestWindowsSmokeRecovery$' + - name: Smoke Windows service lifecycle shell: pwsh run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7594822..87f88f06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,8 @@ permissions: # 竖线分隔在任何错误(含网络错误)时回退到下一个源;默认的逗号只在 # 404/410 时回退,proxy.golang.org 瞬时故障会直接让整个 job 失败。 +# 自托管 Runner 保留本机 Go 缓存;代码与编译参数变化由 Go 自动判定失效。 +# 不再下载/上传 setup-go 缓存,也无需 main push 预热。 env: GOPROXY: https://proxy.golang.org|direct @@ -19,7 +21,7 @@ concurrency: jobs: validate-tag: - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 5 permissions: contents: read @@ -141,7 +143,7 @@ jobs: verify-and-build-web: needs: validate-tag - runs-on: ubuntu-24.04 + runs-on: [self-hosted, macOS, ARM64] timeout-minutes: 10 steps: - name: Checkout tag commit @@ -163,7 +165,7 @@ jobs: static-checks: needs: validate-tag - runs-on: ubuntu-24.04 + runs-on: [self-hosted, macOS, ARM64] timeout-minutes: 20 steps: - name: Checkout tag commit @@ -175,7 +177,7 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - cache: true + cache: false - name: Check Go formatting run: | @@ -206,7 +208,7 @@ jobs: needs: validate-tag # 同一 commit 的 CI 已全绿时跳过;publication-preflight 接受 skipped 结果。 if: ${{ needs.validate-tag.outputs.ci_verified != 'true' }} - runs-on: ubuntu-24.04 + runs-on: [self-hosted, macOS, ARM64] timeout-minutes: 20 steps: - name: Checkout tag commit @@ -214,21 +216,11 @@ jobs: with: persist-credentials: false - - name: Mark race-instrumented Go cache scope - # race-tests、test、database-contract(*) 默认共用同一个基于 go.sum 的 - # setup-go 缓存 key;命中即不保存,race-instrumented GOCACHE 永远无法 - # 写回,每次都要重新编译。加入这个 marker 文件参与 cache-dependency-path - # 哈希,让 race-tests 拥有独立 key 命名空间。 - run: echo race-tests > .go-cache-scope - - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - cache: true - cache-dependency-path: | - go.sum - .go-cache-scope + cache: false - name: Run race-enabled tests run: go test -race -count=1 -timeout=15m . ./internal/... @@ -236,7 +228,7 @@ jobs: race-cpa: needs: validate-tag if: ${{ needs.validate-tag.outputs.ci_verified != 'true' }} - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 10 steps: - name: Checkout tag commit @@ -248,8 +240,7 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - cache: true - cache-dependency-path: third_party/cpaembedded/go.sum + cache: false - name: Run embedded CPA bridge race-enabled tests working-directory: third_party/cpaembedded @@ -259,7 +250,7 @@ jobs: name: Database contract (${{ matrix.driver }}) needs: validate-tag if: ${{ needs.validate-tag.outputs.ci_verified != 'true' }} - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 15 strategy: fail-fast: false @@ -267,34 +258,48 @@ jobs: matrix: include: - driver: mysql - dsn: mysql://root:root@127.0.0.1:3306/gpt_load?charset=utf8mb4&transaction_isolation=%27READ-COMMITTED%27 + image: mysql:8.4 + data-dir: /var/lib/mysql + port: "3306" + health: mysqladmin ping -h 127.0.0.1 -proot + dsn: mysql://root:root@127.0.0.1:{0}/gpt_load?charset=utf8mb4&transaction_isolation=%27READ-COMMITTED%27 - driver: postgres - dsn: postgres://postgres:postgres@127.0.0.1:5432/gpt_load?sslmode=disable + image: postgres:18 + data-dir: /var/lib/postgresql + port: "5432" + health: pg_isready -U postgres -d gpt_load + dsn: postgres://postgres:postgres@127.0.0.1:{0}/gpt_load?sslmode=disable + # 仅临时合同测试数据库使用内存盘;每个实例按实际使用量占用,最多 2 GiB。 services: - mysql: - image: mysql:8.4 + database: + image: ${{ matrix.image }} + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: gpt_load + POSTGRES_PASSWORD: postgres + POSTGRES_DB: gpt_load + ports: + - ${{ matrix.port }} + options: >- + --tmpfs ${{ matrix.data-dir }}:rw,nosuid,nodev,size=2g + --health-cmd="${{ matrix.health }}" + --health-interval=5s + --health-timeout=10s + --health-retries=20 + # MySQL 两组使用独立实例,避免不同测试数据库争用同名 GET_LOCK。 + secondary-mysql: + image: ${{ matrix.driver == 'mysql' && 'mysql:8.4' || '' }} env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: gpt_load ports: - - 3306:3306 + - 3306 options: >- + --tmpfs /var/lib/mysql:rw,nosuid,nodev,size=2g --health-cmd="mysqladmin ping -h 127.0.0.1 -proot" --health-interval=5s --health-timeout=10s --health-retries=20 - postgres: - image: postgres:18 - env: - POSTGRES_PASSWORD: postgres - POSTGRES_DB: gpt_load - ports: - - 5432:5432 - options: >- - --health-cmd="pg_isready -U postgres -d gpt_load" - --health-interval=5s - --health-timeout=10s - --health-retries=20 steps: - name: Checkout tag commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -305,17 +310,17 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - cache: true + cache: false - name: Run external database contract env: - GPT_LOAD_DATABASE_TEST_DSN: ${{ matrix.dsn }} - run: | - go test -v -count=1 ./internal/storage ./internal/control ./internal/requestlog -run '^TestExternal' + GPT_LOAD_DATABASE_TEST_DSN: ${{ format(matrix.dsn, job.services.database.ports[format('{0}', matrix.port)]) }} + GPT_LOAD_DATABASE_SECONDARY_DSN: ${{ matrix.driver == 'mysql' && format(matrix.dsn, job.services.secondary-mysql.ports['3306']) || '' }} + run: .github/scripts/ci-database-contract.sh "${{ matrix.driver }}" build-binaries: needs: verify-and-build-web - runs-on: ubuntu-24.04 + runs-on: ${{ matrix.runner }} timeout-minutes: 15 strategy: fail-fast: true @@ -323,18 +328,23 @@ jobs: matrix: include: - goos: linux + runner: [self-hosted, Linux, ARM64] goarch: amd64 filename: gpt-load-linux-amd64 - goos: linux + runner: [self-hosted, Linux, ARM64] goarch: arm64 filename: gpt-load-linux-arm64 - goos: darwin + runner: [self-hosted, macOS, ARM64] goarch: amd64 filename: gpt-load-macos-amd64 - goos: darwin + runner: [self-hosted, macOS, ARM64] goarch: arm64 filename: gpt-load-macos-arm64 - goos: windows + runner: [self-hosted, Windows, X64] goarch: amd64 filename: gpt-load-windows-amd64.exe steps: @@ -353,9 +363,10 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - cache: true + cache: false - name: Build release binary + shell: bash env: CGO_ENABLED: 0 GOOS: ${{ matrix.goos }} @@ -378,7 +389,7 @@ jobs: build-windows-setup: needs: build-binaries - runs-on: windows-2025 + runs-on: [self-hosted, Windows, X64] timeout-minutes: 10 steps: - name: Checkout tag commit @@ -422,7 +433,7 @@ jobs: package-metadata: needs: validate-tag - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 10 steps: - name: Checkout tag commit @@ -434,7 +445,7 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - cache: true + cache: false - name: Generate release SBOM and license assets run: | @@ -462,7 +473,7 @@ jobs: - build-binaries - build-windows-setup - package-metadata - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 10 steps: - name: Checkout tag commit @@ -522,15 +533,15 @@ jobs: max-parallel: 5 matrix: include: - - runner: ubuntu-24.04 + - runner: [self-hosted, Linux, X64] filename: gpt-load-linux-amd64 - - runner: ubuntu-24.04-arm + - runner: [self-hosted, Linux, ARM64] filename: gpt-load-linux-arm64 - runner: macos-15-intel filename: gpt-load-macos-amd64 - - runner: macos-15 + - runner: [self-hosted, macOS, ARM64] filename: gpt-load-macos-arm64 - - runner: windows-2025 + - runner: [self-hosted, Windows, X64] filename: gpt-load-windows-amd64.exe runs-on: ${{ matrix.runner }} steps: @@ -573,7 +584,7 @@ jobs: windows-installer-smoke: needs: package-checksums - runs-on: windows-2025 + runs-on: [self-hosted, Windows, X64] timeout-minutes: 10 steps: - name: Checkout tag commit @@ -612,9 +623,16 @@ jobs: docker-smoke: needs: validate-tag - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 30 steps: + - name: Isolate Docker credentials + shell: bash + run: | + # 使用临时登录配置,避免覆盖宿主机 Docker 凭据。 + docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")" + echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}" + - name: Checkout tag commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -624,7 +642,7 @@ jobs: shell: bash env: RELEASE_VERSION: ${{ github.ref_name }} - RELEASE_SMOKE_SUFFIX: ${{ github.run_id }} + RELEASE_SMOKE_SUFFIX: ${{ github.run_id }}-${{ github.run_attempt }} RELEASE_SMOKE_TRIVY_IMAGE: aquasec/trivy:0.72.0@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f run: .github/scripts/release-docker-smoke.sh @@ -642,12 +660,19 @@ jobs: # 路径 COPY 的是不同的二进制,必须各自验证。使用原生 arm64 runner 而非 # QEMU:QEMU 模拟下的启动行为不能代表真实 arm64 运行时。 - arch: amd64 - runner: ubuntu-24.04 + runner: [self-hosted, Linux, X64] - arch: arm64 - runner: ubuntu-24.04-arm + runner: [self-hosted, Linux, ARM64] runs-on: ${{ matrix.runner }} timeout-minutes: 30 steps: + - name: Isolate Docker credentials + shell: bash + run: | + # 使用临时登录配置,避免覆盖宿主机 Docker 凭据。 + docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")" + echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}" + - name: Checkout tag commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -663,7 +688,7 @@ jobs: shell: bash env: RELEASE_VERSION: ${{ github.ref_name }} - RELEASE_SMOKE_SUFFIX: prebuilt-${{ matrix.arch }}-${{ github.run_id }} + RELEASE_SMOKE_SUFFIX: prebuilt-${{ matrix.arch }}-${{ github.run_id }}-${{ github.run_attempt }} RELEASE_SMOKE_TRIVY_IMAGE: aquasec/trivy:0.72.0@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f RELEASE_SMOKE_BUILD_TARGET: prebuilt run: .github/scripts/release-docker-smoke.sh @@ -689,7 +714,7 @@ jobs: # race-tests、race-cpa 与 database-contract 在 CI 已验证同一 commit 时为 skipped, # 这里只拒绝真正失败的 gate,不把 skipped 当作缺失的验证。 if: ${{ !cancelled() && !contains(needs.*.result, 'failure') }} - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 20 permissions: # Draft Releases are visible only to identities with push access. @@ -710,6 +735,13 @@ jobs: ghcr_latest_digest: ${{ steps.inventory.outputs.ghcr_latest_digest }} dockerhub_latest_digest: ${{ steps.inventory.outputs.dockerhub_latest_digest }} steps: + - name: Isolate Docker credentials + shell: bash + run: | + # 使用临时登录配置,避免覆盖宿主机 Docker 凭据。 + docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")" + echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}" + - name: Checkout tag commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -945,12 +977,19 @@ jobs: # 间接祖先因 CI 结论复用而 skipped,publish-images 也会被默认条件连带跳过, # 即便这里列出的直接 needs 全部成功。显式条件只看直接 needs,绕开这个传播。 if: ${{ !cancelled() && needs.validate-tag.result == 'success' && needs.publication-preflight.result == 'success' }} - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 45 permissions: contents: read packages: write steps: + - name: Isolate Docker credentials + shell: bash + run: | + # 使用临时登录配置,避免覆盖宿主机 Docker 凭据。 + docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")" + echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}" + - name: Checkout tag commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -968,7 +1007,7 @@ jobs: if: ${{ needs.publication-preflight.outputs.write_mode == 'publish' }} uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 with: - platforms: arm64 + platforms: amd64 - name: Set up Docker Buildx uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 @@ -1054,7 +1093,7 @@ jobs: - publish-images # 同上:显式条件只看直接 needs,避免默认条件把间接祖先的 skip 传播下来。 if: ${{ !cancelled() && needs.validate-tag.result == 'success' && needs.publication-preflight.result == 'success' && needs.publish-images.result == 'success' }} - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 10 permissions: contents: write @@ -1141,12 +1180,19 @@ jobs: - publish-images # 同上:显式条件只看直接 needs,避免默认条件把间接祖先的 skip 传播下来。 if: ${{ !cancelled() && needs.validate-tag.result == 'success' && needs.publish-images.result == 'success' }} - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 30 permissions: contents: read packages: read steps: + - name: Isolate Docker credentials + shell: bash + run: | + # 使用临时登录配置,避免覆盖宿主机 Docker 凭据。 + docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")" + echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}" + - name: Checkout tag commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -1164,7 +1210,7 @@ jobs: env: RELEASE_VERSION: ${{ github.ref_name }} RELEASE_SMOKE_SOURCE_IMAGE: ghcr.io/tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }} - RELEASE_SMOKE_SUFFIX: ${{ github.run_id }}-ghcr + RELEASE_SMOKE_SUFFIX: ${{ github.run_id }}-${{ github.run_attempt }}-ghcr # 同一 commit 的镜像已在 docker-smoke 扫描过,这里不重复扫描。 RELEASE_SMOKE_SKIP_SCAN: "true" run: .github/scripts/release-docker-smoke.sh @@ -1177,13 +1223,20 @@ jobs: - publish-images # 同上:显式条件只看直接 needs,避免默认条件把间接祖先的 skip 传播下来。 if: ${{ !cancelled() && needs.validate-tag.result == 'success' && needs.publication-preflight.result == 'success' && needs.publish-github.result == 'success' && needs.publish-images.result == 'success' }} - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 45 permissions: # Draft Releases are visible only to identities with push access. contents: write packages: read steps: + - name: Isolate Docker credentials + shell: bash + run: | + # 使用临时登录配置,避免覆盖宿主机 Docker 凭据。 + docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")" + echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}" + - name: Checkout tag commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -1313,7 +1366,7 @@ jobs: group: gpt-load-v2-image-channels cancel-in-progress: false queue: max - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 20 permissions: contents: read @@ -1322,6 +1375,13 @@ jobs: beta_current: ${{ steps.promote.outputs.beta_current }} major_current: ${{ steps.promote.outputs.major_current }} steps: + - name: Isolate Docker credentials + shell: bash + run: | + # 使用临时登录配置,避免覆盖宿主机 Docker 凭据。 + docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")" + echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}" + - name: Checkout tag commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -1376,7 +1436,7 @@ jobs: environment: name: render url: ${{ vars.RENDER_SERVICE_URL }} - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 30 permissions: contents: read @@ -1386,7 +1446,7 @@ jobs: RELEASE_VERSION: ${{ needs.validate-tag.outputs.version }} IMAGE: ghcr.io/tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }} RENDER_CLI_VERSION: "2.25.0" - RENDER_CLI_SHA256: "3b3f1f839ef36b81f12d84ac7288f1c96f9f7519b39c53fe6f866612f704e7cd" + RENDER_CLI_SHA256: "7c4dc66ded7acc75e2f828b02d3d6901e9f2e4175d25c4fe5fd6f3aaf66b071b" steps: - name: Validate deployment inputs shell: bash @@ -1401,7 +1461,7 @@ jobs: shell: bash run: | set -euo pipefail - archive="cli_${RENDER_CLI_VERSION}_linux_amd64.zip" + archive="cli_${RENDER_CLI_VERSION}_linux_arm64.zip" archive_path="${RUNNER_TEMP}/${archive}" extract_dir="${RUNNER_TEMP}/render-cli-${RENDER_CLI_VERSION}" url="https://github.com/render-oss/cli/releases/download/v${RENDER_CLI_VERSION}/${archive}" @@ -1496,13 +1556,20 @@ jobs: - post-publish-image-smoke - post-publish-verify - promote-image-channels - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, ARM64] timeout-minutes: 10 permissions: # Draft Releases are visible only to identities with push access. contents: write packages: read steps: + - name: Isolate Docker credentials + shell: bash + run: | + # 使用临时登录配置,避免覆盖宿主机 Docker 凭据。 + docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX")" + echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}" + - name: Checkout tag commit for read-only reconciliation if: ${{ always() }} continue-on-error: true diff --git a/internal/storage/access_key_prefix_migration_test.go b/internal/storage/access_key_prefix_migration_test.go index 9e32a8be..2d91a276 100644 --- a/internal/storage/access_key_prefix_migration_test.go +++ b/internal/storage/access_key_prefix_migration_test.go @@ -9,6 +9,7 @@ import ( ) func TestAccessKeyPrefixMigrationContract(t *testing.T) { + t.Parallel() testAccessKeyPrefixMigration(t, openInternalMigrationTestDatabase) } @@ -24,6 +25,9 @@ func testAccessKeyPrefixMigration(t *testing.T, open func(*testing.T) *gorm.DB) for _, scenario := range []string{"fresh", "upgrade", "interrupted"} { t.Run(scenario, func(t *testing.T) { db := open(t) + if db.Dialector.Name() == "sqlite" { + t.Parallel() + } if scenario != "fresh" { if err := applyMigrationRegistry(db, migrations[:11]); err != nil { t.Fatal(err) diff --git a/internal/storage/custom_access_key_migration_test.go b/internal/storage/custom_access_key_migration_test.go index 856a3552..2b13cfb8 100644 --- a/internal/storage/custom_access_key_migration_test.go +++ b/internal/storage/custom_access_key_migration_test.go @@ -11,6 +11,7 @@ import ( ) func TestCustomAccessKeyMigrationContract(t *testing.T) { + t.Parallel() testCustomAccessKeyMigration(t, openInternalMigrationTestDatabase) } @@ -26,6 +27,9 @@ func testCustomAccessKeyMigration(t *testing.T, open func(*testing.T) *gorm.DB) for _, scenario := range []string{"fresh", "upgrade", "interrupted"} { t.Run(scenario, func(t *testing.T) { db := open(t) + if db.Dialector.Name() == "sqlite" { + t.Parallel() + } var key models.AccessKey var rule models.AccessKeyCostLimitRule var deletedID uint diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 1f749007..693ddbc2 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -205,6 +205,7 @@ func TestOpenCreatesSQLiteDatabase(t *testing.T) { } func TestOpenWithSourceManagedRejectsMissingParentWithoutCreation(t *testing.T) { + t.Parallel() root := t.TempDir() parent := filepath.Join(root, "startup-must-create") dsn := filepath.Join(parent, "managed.db") @@ -225,6 +226,7 @@ func TestOpenWithSourceManagedRejectsMissingParentWithoutCreation(t *testing.T) } func TestOpenWithSourceExternalRejectsMissingParentWithoutCreation(t *testing.T) { + t.Parallel() root := t.TempDir() parent := filepath.Join(root, "operator-must-create") dsn := filepath.Join(parent, "external.db") @@ -245,6 +247,7 @@ func TestOpenWithSourceExternalRejectsMissingParentWithoutCreation(t *testing.T) } func TestOpenAllowsUnversionedFileWithExternalTables(t *testing.T) { + t.Parallel() path := filepath.Join(t.TempDir(), "external-tables.db") db, err := storage.Open(path) if err != nil { @@ -369,6 +372,7 @@ func TestOpenWithSourceExternalHintDoesNotExposeDSN(t *testing.T) { } func TestOpenWithSourceFileModeMemoryURIStaysInMemory(t *testing.T) { + t.Parallel() databasePath := filepath.Join(t.TempDir(), "named-memory.db") dsn := "file:" + filepath.ToSlash(databasePath) + "?mode=memory&cache=shared" @@ -398,6 +402,7 @@ func TestOpenWithSourceFileModeMemoryURIStaysInMemory(t *testing.T) { } func TestOpenWithSourceColonMemoryQueryStaysInMemory(t *testing.T) { + t.Parallel() db, err := storage.OpenWithSource( ":memory:?cache=shared", config.DatabaseSourceExternal, @@ -450,6 +455,7 @@ func assertSQLiteMemoryDatabase(t *testing.T, db *gorm.DB) { } func TestOpenOverridesSQLiteRuntimeOptions(t *testing.T) { + t.Parallel() dsn := filepath.Join(t.TempDir(), "runtime.db") + "?_txlock=deferred&_pragma=foreign_keys(0)" + "&_pragma=busy_timeout(1)&_pragma=journal_mode(DELETE)" @@ -487,6 +493,7 @@ func TestOpenOverridesSQLiteRuntimeOptions(t *testing.T) { } func TestOpenDoesNotForceWALForMemoryDatabase(t *testing.T) { + t.Parallel() db, err := storage.Open(":memory:") if err != nil { t.Fatalf("Open() error = %v", err) @@ -510,6 +517,7 @@ func TestOpenDoesNotForceWALForMemoryDatabase(t *testing.T) { } func TestOpenUsesImmediateTransactions(t *testing.T) { + t.Parallel() dsn := filepath.Join(t.TempDir(), "immediate.db") appDB, err := storage.Open(dsn) if err != nil { @@ -681,6 +689,7 @@ func TestAutoMigrateCreatesUsageJournalAndMigrationLedger(t *testing.T) { } func TestAutoMigrateRejectsRetiredV2MigrationLedgers(t *testing.T) { + t.Parallel() for _, retiredID := range []string{"0001_initial_v2", "0001_final_v2"} { t.Run(retiredID, func(t *testing.T) { db, err := storage.Open(":memory:") @@ -718,6 +727,7 @@ func TestAutoMigrateRejectsRetiredV2MigrationLedgers(t *testing.T) { } func TestAutoMigrateRejectsAppliedMigrationWithIncompleteSchema(t *testing.T) { + t.Parallel() db, err := storage.Open(":memory:") if err != nil { t.Fatalf("Open(:memory:) error = %v", err) diff --git a/internal/storage/migration_recovery_test.go b/internal/storage/migration_recovery_test.go index ca8c58fd..39c874c0 100644 --- a/internal/storage/migration_recovery_test.go +++ b/internal/storage/migration_recovery_test.go @@ -15,9 +15,11 @@ import ( ) func TestApplyMySQLMigrationRecoversEveryInitialDDLBoundary(t *testing.T) { + t.Parallel() models := migrationfiles.SchemaModels0001() for boundary := 0; boundary <= len(models); boundary++ { t.Run(migrationfiles.TableNames0001()[boundaryOrLast(boundary, len(models))], func(t *testing.T) { + t.Parallel() db := openInternalMigrationTestDatabase(t) if err := db.AutoMigrate(&schemaMigration{}); err != nil { t.Fatalf("create migration ledger: %v", err) @@ -41,9 +43,11 @@ func TestApplyMySQLMigrationRecoversEveryInitialDDLBoundary(t *testing.T) { } func TestApplyMySQLMigrationRecoversEveryAccessKeyCostLimitDDLBoundary(t *testing.T) { + t.Parallel() models := migrationfiles.SchemaModels0002() for boundary := 0; boundary <= len(models); boundary++ { t.Run(migrationfiles.TableNames0002()[boundaryOrLast(boundary, len(models))], func(t *testing.T) { + t.Parallel() db := openInternalMigrationTestDatabase(t) if err := db.AutoMigrate(&schemaMigration{}); err != nil { t.Fatalf("create migration ledger: %v", err) @@ -75,8 +79,10 @@ func TestApplyMySQLMigrationRecoversEveryAccessKeyCostLimitDDLBoundary(t *testin } func TestApplyMySQLMigrationRecoversObservationFreshnessRemoval(t *testing.T) { + t.Parallel() for _, alreadyDropped := range []bool{false, true} { t.Run(fmt.Sprintf("already_dropped_%t", alreadyDropped), func(t *testing.T) { + t.Parallel() db := openInternalMigrationTestDatabase(t) if err := db.AutoMigrate(&schemaMigration{}); err != nil { t.Fatalf("create migration ledger: %v", err) @@ -111,8 +117,10 @@ func TestApplyMySQLMigrationRecoversObservationFreshnessRemoval(t *testing.T) { } func TestApplyMySQLMigrationRecoversAccessKeyLifecycleAddition(t *testing.T) { + t.Parallel() for _, columnAlreadyAdded := range []bool{false, true} { t.Run(fmt.Sprintf("column_already_added_%t", columnAlreadyAdded), func(t *testing.T) { + t.Parallel() db := openInternalMigrationTestDatabase(t) if err := db.AutoMigrate(&schemaMigration{}); err != nil { t.Fatalf("create migration ledger: %v", err) @@ -150,6 +158,7 @@ func TestApplyMySQLMigrationRecoversAccessKeyLifecycleAddition(t *testing.T) { } func TestApplyMySQLMigrationRejectsUnsafeResumeState(t *testing.T) { + t.Parallel() tests := []struct { name string setup func(*testing.T, internalMigrationDB) @@ -190,6 +199,7 @@ func TestApplyMySQLMigrationRejectsUnsafeResumeState(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { + t.Parallel() db := openInternalMigrationTestDatabase(t) if err := db.AutoMigrate(&schemaMigration{}); err != nil { t.Fatal(err) diff --git a/internal/storage/migrations/0001_initial_test.go b/internal/storage/migrations/0001_initial_test.go index e24a9b1d..476359f3 100644 --- a/internal/storage/migrations/0001_initial_test.go +++ b/internal/storage/migrations/0001_initial_test.go @@ -22,6 +22,7 @@ type initialColumn struct { } func TestBeta1InitialMigrationCreatesCompleteFinalSchema(t *testing.T) { + t.Parallel() db := openInitialTestDatabase(t) if err := migrations.Up0001(db); err != nil { t.Fatalf("Up0001() error = %v", err) @@ -105,6 +106,7 @@ func insertBeta1CredentialStage(db *gorm.DB, id, method string) error { } func TestBeta1InitialMigrationCascadesCredentialObservations(t *testing.T) { + t.Parallel() db := openMigratedInitialTestDatabase(t) if err := db.Exec(`INSERT INTO groups ( id, name, channel_id, connection_type, params, models, enabled, created_at_ms, updated_at_ms @@ -136,6 +138,7 @@ func TestBeta1InitialMigrationCascadesCredentialObservations(t *testing.T) { } func TestAutoMigrateCreatesFinalPricingSchema(t *testing.T) { + t.Parallel() db := openInitialTestDatabase(t) if err := storage.AutoMigrate(db); err != nil { t.Fatal(err) @@ -178,6 +181,7 @@ func TestAutoMigrateCreatesFinalPricingSchema(t *testing.T) { } func TestAutoMigrateCreatesNormalizedRequestLogInitialSchema(t *testing.T) { + t.Parallel() db := openInitialTestDatabase(t) if err := storage.AutoMigrate(db); err != nil { t.Fatal(err) @@ -235,6 +239,7 @@ func TestAutoMigrateCreatesNormalizedRequestLogInitialSchema(t *testing.T) { } func TestAutoMigrateAllowsUnrelatedSchemaInfoTable(t *testing.T) { + t.Parallel() db := openInitialTestDatabase(t) if err := db.Exec("CREATE TABLE schema_info (version integer PRIMARY KEY)").Error; err != nil { t.Fatal(err) @@ -251,6 +256,7 @@ func TestAutoMigrateAllowsUnrelatedSchemaInfoTable(t *testing.T) { } func TestAutoMigrateCreatesFinalCatalogAndUsageColumns(t *testing.T) { + t.Parallel() db := openInitialTestDatabase(t) if err := storage.AutoMigrate(db); err != nil { t.Fatal(err) @@ -321,6 +327,7 @@ func TestAutoMigrateCreatesFinalCatalogAndUsageColumns(t *testing.T) { } func TestFinalModelPriceRejectsNegativeScalarPrices(t *testing.T) { + t.Parallel() db := openMigratedInitialTestDatabase(t) priceColumns := []string{ "input_price_nano_usd_per_million_tokens", @@ -341,6 +348,7 @@ func TestFinalModelPriceRejectsNegativeScalarPrices(t *testing.T) { } func TestModelPriceValidatesAndNormalizesContextTiersBeforePersistence(t *testing.T) { + t.Parallel() db := openMigratedInitialTestDatabase(t) empty := models.ModelPrice{ @@ -405,6 +413,7 @@ func TestModelPriceValidatesAndNormalizesContextTiersBeforePersistence(t *testin } func TestModelPriceValidatesContextTiersAcrossGORMWritePaths(t *testing.T) { + t.Parallel() type writePath struct { name string write func(*gorm.DB, models.ModelPrice, models.JSON) error @@ -469,6 +478,7 @@ func TestModelPriceValidatesContextTiersAcrossGORMWritePaths(t *testing.T) { } func TestFinalRequestLogEnforcesStateContract(t *testing.T) { + t.Parallel() db := openMigratedInitialTestDatabase(t) valid := []requestStateFixture{ {id: "valid-priced-complete", status: "success", usage: "complete", cost: "priced", pricing: "complete", estimatedCost: 1}, @@ -501,6 +511,7 @@ func TestFinalRequestLogEnforcesStateContract(t *testing.T) { } func TestFinalSchemaUsesMillisecondIntegersAndEnforcesCounters(t *testing.T) { + t.Parallel() db := openMigratedInitialTestDatabase(t) for table, names := range map[string][]string{ @@ -633,6 +644,7 @@ func TestFinalSchemaUsesMillisecondIntegersAndEnforcesCounters(t *testing.T) { } func TestAutoMigrateRejectsChangedTableForCompletedMigration(t *testing.T) { + t.Parallel() db := openMigratedInitialTestDatabase(t) if err := db.Exec(`ALTER TABLE usage_stats RENAME TO usage_stats_checked`).Error; err != nil { t.Fatal(err) @@ -651,6 +663,7 @@ func TestAutoMigrateRejectsChangedTableForCompletedMigration(t *testing.T) { } func TestAutoMigrateRejectsMissingIndexForCompletedMigration(t *testing.T) { + t.Parallel() db := openMigratedInitialTestDatabase(t) if err := db.Exec("DROP INDEX idx_model_prices_channel_model").Error; err != nil { t.Fatal(err) diff --git a/internal/storage/migrations/0003_remove_observation_fresh_until_internal_test.go b/internal/storage/migrations/0003_remove_observation_fresh_until_internal_test.go index 4677d118..4c4af1e7 100644 --- a/internal/storage/migrations/0003_remove_observation_fresh_until_internal_test.go +++ b/internal/storage/migrations/0003_remove_observation_fresh_until_internal_test.go @@ -3,6 +3,7 @@ package migrations import "testing" func TestMySQLRequiresCheckDropSyntax0003(t *testing.T) { + t.Parallel() for _, test := range []struct { serverVersion string want bool diff --git a/internal/storage/migrations/0003_remove_observation_fresh_until_test.go b/internal/storage/migrations/0003_remove_observation_fresh_until_test.go index a8186e93..39db158a 100644 --- a/internal/storage/migrations/0003_remove_observation_fresh_until_test.go +++ b/internal/storage/migrations/0003_remove_observation_fresh_until_test.go @@ -8,6 +8,7 @@ import ( ) func TestRemoveObservationFreshUntilMigrationDropsColumnAndPreservesRows(t *testing.T) { + t.Parallel() db := openInitialTestDatabase(t) if err := migrations.Up0001(db); err != nil { t.Fatalf("Up0001() error = %v", err) @@ -61,6 +62,7 @@ func TestRemoveObservationFreshUntilMigrationDropsColumnAndPreservesRows(t *test } func TestRemoveObservationFreshUntilRecoveryAcceptsDroppedCheckBeforeColumn(t *testing.T) { + t.Parallel() db := openInitialTestDatabase(t) if err := migrations.Up0001(db); err != nil { t.Fatalf("Up0001() error = %v", err) diff --git a/internal/storage/migrations/0004_usage_stats_group_activity_index_test.go b/internal/storage/migrations/0004_usage_stats_group_activity_index_test.go index 47335f24..9565a31b 100644 --- a/internal/storage/migrations/0004_usage_stats_group_activity_index_test.go +++ b/internal/storage/migrations/0004_usage_stats_group_activity_index_test.go @@ -7,6 +7,7 @@ import ( ) func TestUsageStatsGroupActivityIndexMigrationCreatesAndValidatesIndex(t *testing.T) { + t.Parallel() db := openInitialTestDatabase(t) if err := migrations.Up0001(db); err != nil { t.Fatal(err) @@ -38,6 +39,7 @@ func TestUsageStatsGroupActivityIndexMigrationCreatesAndValidatesIndex(t *testin } func TestUsageStatsGroupActivityIndexMigrationRejectsMissingIndex(t *testing.T) { + t.Parallel() db := openInitialTestDatabase(t) if err := migrations.Up0001(db); err != nil { t.Fatal(err) diff --git a/internal/storage/migrations/0005_proxy_config_test.go b/internal/storage/migrations/0005_proxy_config_test.go index 9586debb..317a7920 100644 --- a/internal/storage/migrations/0005_proxy_config_test.go +++ b/internal/storage/migrations/0005_proxy_config_test.go @@ -7,6 +7,7 @@ import ( ) func TestProxyConfigMigrationAddsNullableEncryptedColumnsAndPreservesRows(t *testing.T) { + t.Parallel() db := openInitialTestDatabase(t) for _, migrate := range []func() error{ func() error { return migrations.Up0001(db) }, @@ -69,6 +70,7 @@ func TestProxyConfigMigrationAddsNullableEncryptedColumnsAndPreservesRows(t *tes } func TestProxyConfigMigrationRecoverableValidationAcceptsPartialColumns(t *testing.T) { + t.Parallel() for _, test := range []struct { name string statement string diff --git a/internal/storage/migrations/0006_error_decision_test.go b/internal/storage/migrations/0006_error_decision_test.go index 3b109a8b..7aab77d4 100644 --- a/internal/storage/migrations/0006_error_decision_test.go +++ b/internal/storage/migrations/0006_error_decision_test.go @@ -8,6 +8,7 @@ import ( ) func TestErrorDecisionMigrationPreservesAttemptsAndAddsDecisionContract(t *testing.T) { + t.Parallel() db := openInitialTestDatabase(t) if err := migrations.Up0001(db); err != nil { t.Fatalf("Up0001() error = %v", err) @@ -83,6 +84,7 @@ func TestErrorDecisionMigrationPreservesAttemptsAndAddsDecisionContract(t *testi } func TestErrorDecisionMigrationPreservesAttemptIndexesAndForeignKey(t *testing.T) { + t.Parallel() db := openInitialTestDatabase(t) if err := migrations.Up0001(db); err != nil { t.Fatal(err) diff --git a/internal/storage/migrations/0007_access_key_lifecycle_test.go b/internal/storage/migrations/0007_access_key_lifecycle_test.go index 1795a1ed..57640054 100644 --- a/internal/storage/migrations/0007_access_key_lifecycle_test.go +++ b/internal/storage/migrations/0007_access_key_lifecycle_test.go @@ -8,6 +8,7 @@ import ( ) func TestAccessKeyLifecycleMigrationAddsNullableExpiryAndPreservesRows(t *testing.T) { + t.Parallel() db := openInitialTestDatabase(t) if err := migrations.Up0001(db); err != nil { t.Fatal(err) @@ -44,6 +45,7 @@ func TestAccessKeyLifecycleMigrationAddsNullableExpiryAndPreservesRows(t *testin } func TestAccessKeyLifecycleMigrationValidationRejectsMissingColumn(t *testing.T) { + t.Parallel() db := openInitialTestDatabase(t) if err := migrations.Up0001(db); err != nil { t.Fatal(err) diff --git a/internal/storage/model_cooldown_migration_test.go b/internal/storage/model_cooldown_migration_test.go index 108c3489..0b9fb875 100644 --- a/internal/storage/model_cooldown_migration_test.go +++ b/internal/storage/model_cooldown_migration_test.go @@ -12,6 +12,7 @@ import ( ) func TestModelCooldownMigrationContract(t *testing.T) { + t.Parallel() testModelCooldownMigration(t, openInternalMigrationTestDatabase) } @@ -62,6 +63,9 @@ func testModelCooldownMigration(t *testing.T, open func(*testing.T) *gorm.DB) { for _, scenario := range []string{"fresh", "upgrade", "interrupted", "column_added"} { t.Run(scenario, func(t *testing.T) { db := open(t) + if db.Dialector.Name() == "sqlite" { + t.Parallel() + } if scenario != "fresh" { if err := applyMigrationRegistry(db, migrations[:9]); err != nil { t.Fatal(err) diff --git a/internal/storage/price_multiplier_migration_test.go b/internal/storage/price_multiplier_migration_test.go index 62c46621..50352cc9 100644 --- a/internal/storage/price_multiplier_migration_test.go +++ b/internal/storage/price_multiplier_migration_test.go @@ -13,6 +13,7 @@ import ( ) func TestPriceMultiplierMigrationAddsConfigurationColumns(t *testing.T) { + t.Parallel() db := openInternalMigrationTestDatabase(t) if err := AutoMigrate(db); err != nil { t.Fatal(err) @@ -25,6 +26,7 @@ func TestPriceMultiplierMigrationAddsConfigurationColumns(t *testing.T) { } func TestPriceMultiplierMigrationUpgradeAndRecovery(t *testing.T) { + t.Parallel() testPriceMultiplierMigrationContract(t, func(t *testing.T) *gorm.DB { return openInternalMigrationTestDatabase(t) }) } @@ -41,6 +43,9 @@ func testPriceMultiplierMigrationContract(t *testing.T, open func(*testing.T) *g for _, mode := range []string{"fresh", "upgrade", "interrupted", "columns_added"} { t.Run(mode, func(t *testing.T) { db := open(t) + if db.Dialector.Name() == "sqlite" { + t.Parallel() + } if mode != "fresh" { if err := applyMigrationRegistry(db, migrations[:8]); err != nil { t.Fatal(err) diff --git a/internal/storage/validation_protocol_migration_test.go b/internal/storage/validation_protocol_migration_test.go index be6d7237..114aef41 100644 --- a/internal/storage/validation_protocol_migration_test.go +++ b/internal/storage/validation_protocol_migration_test.go @@ -9,6 +9,7 @@ import ( ) func TestValidationProtocolMigrationContract(t *testing.T) { + t.Parallel() testValidationProtocolMigration(t, openInternalMigrationTestDatabase) } @@ -24,6 +25,9 @@ func testValidationProtocolMigration(t *testing.T, open func(*testing.T) *gorm.D for _, scenario := range []string{"fresh", "upgrade", "interrupted"} { t.Run(scenario, func(t *testing.T) { db := open(t) + if db.Dialector.Name() == "sqlite" { + t.Parallel() + } if scenario != "fresh" { if err := applyMigrationRegistry(db, migrations[:12]); err != nil { t.Fatal(err) diff --git a/internal/webui/windows_installer_contract_test.go b/internal/webui/windows_installer_contract_test.go index 138cdcc2..31ae7967 100644 --- a/internal/webui/windows_installer_contract_test.go +++ b/internal/webui/windows_installer_contract_test.go @@ -19,7 +19,7 @@ func TestWindowsInstallerKeepsPortableBinaryAndAddsSetupAsset(t *testing.T) { workflow := readRepositoryFile(t, ".github/workflows/release.yml") setupJob := workflowJobBlock(t, workflow, "build-windows-setup") for _, required := range []string{ - "windows-2025", + "runs-on: [self-hosted, Windows, X64]", "binary-gpt-load-windows-amd64.exe", "packaging/windows/gpt-load.iss", "ISCC.exe", @@ -172,6 +172,43 @@ func TestWindowsInstallerCleansFreshInstallFailureWithoutDeletingProgramData(t * } } +func TestWindowsInstallerSmokeUsesAnAssignedPortForConflictAndHealth(t *testing.T) { + script := readRepositoryFile(t, ".github/scripts/release-windows-installer-smoke.ps1") + for _, required := range []string{ + "[System.Net.IPAddress]::Loopback,\n 0", + "$listener.ExclusiveAddressUse = $true", + "$port = $listener.LocalEndpoint.Port", + `$envFile = Join-Path $configDir ".env"`, + `"HOST=127.0.0.1"`, + `"PORT=$port"`, + "Set-Content -Path $envFile -Encoding utf8NoBOM", + "http://127.0.0.1:$port/health", + "http://127.0.0.1:$port/api/system/info", + } { + if !strings.Contains(script, required) { + t.Fatalf("installer smoke does not use the assigned port: missing %q", required) + } + } + if strings.Contains(script, "3001") { + t.Fatal("installer smoke still depends on the host's fixed application port") + } + previous := -1 + for _, step := range []string{ + "$listener.Start()", + "$port = $listener.LocalEndpoint.Port", + "Set-Content -Path $envFile -Encoding utf8NoBOM", + "Invoke-CheckedProcess -Path $setup", + "-ExpectedExitCode 10", + "$listener.Stop()", + } { + index := strings.Index(script, step) + if index <= previous { + t.Fatalf("installer smoke must hold its configured port until the conflict check finishes: %s", step) + } + previous = index + } +} + func TestPullRequestWindowsCIBuildsAndSmokesInstaller(t *testing.T) { workflow := readRepositoryFile(t, ".github/workflows/ci.yml") windowsJob := workflowJobBlock(t, workflow, "windows-encryption-acl") @@ -201,7 +238,7 @@ func TestWindowsInstallerSmokeCoversInstallHealthStopAndUninstall(t *testing.T) "RELEASE_WINDOWS_BINARY", "/VERYSILENT", "Get-Service", - "http://127.0.0.1:3001/health", + "http://127.0.0.1:$port/health", "auth.key", "Authorization", "service stop", @@ -262,6 +299,8 @@ func TestWindowsSmokesOwnTheirFixedInstallationDirectory(t *testing.T) { "refusing pre-existing installation directory", "installOwnerMarker", "installOwnerToken", + "Enter-WindowsSmoke -InstallDir $installDir -ConfigDir $configDir", + "$smokeMutex.ReleaseMutex()", } { if !strings.Contains(script, required) { t.Fatalf("%s does not contain fixed install ownership contract %q", path, required) @@ -270,5 +309,8 @@ func TestWindowsSmokesOwnTheirFixedInstallationDirectory(t *testing.T) { if strings.Contains(script, `"/DIR=`) { t.Fatalf("%s overrides the fixed installer directory", path) } + if strings.Index(script, "Enter-WindowsSmoke -InstallDir") > strings.Index(script, "refusing pre-existing Windows service") { + t.Fatalf("%s rejects old smoke installations before recovering them", path) + } } } diff --git a/internal/webui/windows_smoke_recovery_test.go b/internal/webui/windows_smoke_recovery_test.go new file mode 100644 index 00000000..f60ec4d9 --- /dev/null +++ b/internal/webui/windows_smoke_recovery_test.go @@ -0,0 +1,156 @@ +package webui + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestWindowsSmokeRecovery(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + t.Skip("PowerShell recovery tests run in the Windows CI job") + } + script := ` +$ErrorActionPreference = 'Stop' +. $env:RECOVERY_SCRIPT +$script:record = $null +$script:stops = 0 +$script:deletes = 0 +function Get-CimInstance { [CmdletBinding()] param($ClassName, $Filter); return $script:record } +function Get-Service { + [CmdletBinding()] param($Name) + $controller = [pscustomobject]@{ Status = 'Running' } + $controller | Add-Member ScriptMethod Stop { $script:stops++ } + $controller | Add-Member ScriptMethod WaitForStatus { param($Status, $Timeout) } + $controller | Add-Member ScriptMethod Dispose { } + return $controller +} +function sc.exe { + param($Action, $Name) + if ($Action -ne 'delete' -or $Name -ne 'gpt-load') { throw 'unexpected service operation' } + $script:deletes++ + $global:LASTEXITCODE = 0 +} +# Windows 系统目录与注册表只在真实 smoke 中使用;此测试只操作临时目录。 +function Test-Path { + [CmdletBinding()] param([string]$LiteralPath) + if (-not $LiteralPath.StartsWith($env:TEST_ROOT)) { return $false } + return Microsoft.PowerShell.Management\Test-Path -LiteralPath $LiteralPath +} + +foreach ($case in @('unmarked', 'invalid', 'mismatch', 'foreign-service', 'service', 'partial', 'installer', 'linked')) { + $root = Join-Path $env:TEST_ROOT $case + $install = Join-Path $root 'install' + $config = Join-Path $root 'config' + New-Item -ItemType Directory -Path $install, $config | Out-Null + $token = [guid]::NewGuid().ToString('N') + $marker = if ($case -eq 'installer') { '.installer-smoke-owner' } else { '.service-smoke-owner' } + if ($case -ne 'unmarked') { + $value = if ($case -eq 'invalid') { 'not-a-test-owner' } else { $token } + [IO.File]::WriteAllText((Join-Path $config $marker), $value) + if ($case -ne 'installer') { + $value = if ($case -eq 'mismatch') { [guid]::NewGuid().ToString('N') } else { $value } + [IO.File]::WriteAllText((Join-Path $install $marker), $value) + } else { + New-Item -ItemType Directory -Path (Join-Path $config 'data') | Out-Null + [IO.File]::WriteAllText((Join-Path $config 'data/installer-smoke-failure.txt'), $token) + } + } + if ($case -eq 'partial') { Microsoft.PowerShell.Management\Remove-Item -LiteralPath $config -Recurse -Force } + if ($case -eq 'linked') { + Microsoft.PowerShell.Management\Remove-Item -LiteralPath $install -Recurse -Force + New-Item -ItemType SymbolicLink -Path $install -Target $config | Out-Null + } + $script:record = if ($case -in @('service', 'foreign-service')) { + $binary = if ($case -eq 'service') { Join-Path $install 'gpt-load.exe' } else { Join-Path $root 'real-app.exe' } + [pscustomobject]@{ PathName = '"' + $binary + '" service run'; StartName = 'NT AUTHORITY\LocalService' } + } else { $null } + $lock = $null + $failed = $false + try { $lock = Enter-WindowsSmoke -InstallDir $install -ConfigDir $config } + catch { $failed = $true } + finally { if ($null -ne $lock) { $lock.ReleaseMutex(); $lock.Dispose() } } + $wantFailure = $case -in @('invalid', 'mismatch', 'foreign-service', 'linked') + if ($failed -ne $wantFailure) { throw "$case failure = $failed, want $wantFailure" } + $wantPreserved = $wantFailure -or $case -eq 'unmarked' + if ((Test-Path $install) -ne $wantPreserved) { throw "$case installation preservation failed" } + if ((Test-Path $config) -ne $wantPreserved) { throw "$case data preservation failed" } +} +if ($script:stops -ne 1 -or $script:deletes -ne 1) { throw 'stopped or deleted an unowned service' } + +# 第二个进程不能清理第一个进程仍在使用的安装目录。 +$script:record = $null +$root = Join-Path $env:TEST_ROOT 'busy' +$lock = Enter-WindowsSmoke -InstallDir $root -ConfigDir $root +try { + & ([Environment]::ProcessPath) -NoLogo -NoProfile -Command ' + $ErrorActionPreference = "Stop" + . $env:RECOVERY_SCRIPT + try { + $lock = Enter-WindowsSmoke -InstallDir $env:TEST_ROOT -ConfigDir $env:TEST_ROOT + $lock.ReleaseMutex(); $lock.Dispose() + exit 2 + } catch { + if ($_.Exception.Message -ne "another Windows smoke is running") { throw } + exit 0 + } + ' + if ($LASTEXITCODE -ne 0) { throw 'active smoke was not protected' } +} finally { $lock.ReleaseMutex(); $lock.Dispose() } + +# 在初始化的每条完整语句后模拟强制中断:固定目录只能以完整状态出现。 +$source = [IO.File]::ReadAllText($env:INSTALLER_SCRIPT) +$start = $source.IndexOf(' [System.IO.File]::WriteAllText($installOwnerMarker, $installOwnerToken)') +$end = $source.IndexOf(' $listener =', $start) +if ($start -lt 0 -or $end -le $start) { throw 'missing installer initialization block' } +$lines = $source.Substring($start, $end - $start).Trim().Split([char]10) +foreach ($cut in 1..($lines.Count + 1)) { + $programData = Join-Path $env:TEST_ROOT "init-$cut" + New-Item -ItemType Directory -Path $programData | Out-Null + $suffix = [guid]::NewGuid().ToString('N') + $installOwnerToken = $suffix + $installOwnerMarker = Join-Path $programData 'install.owner' + $configDir = Join-Path $programData 'GPT-Load' + $dataDir = Join-Path $configDir 'data' + $dataOwnerMarker = Join-Path $configDir '.installer-smoke-owner' + $failureDataMarker = Join-Path $dataDir 'installer-smoke-failure.txt' + $preparedConfig = Join-Path $programData ".gpt-load-installer-smoke-$suffix" + $ownsPreparedConfig = $false + $conflict = $cut -gt $lines.Count + if ($conflict) { + New-Item -ItemType Directory -Path $configDir | Out-Null + [IO.File]::WriteAllText((Join-Path $configDir 'real-data'), 'keep') + } + $count = [Math]::Min($cut, $lines.Count) + $failed = $false + try { . ([scriptblock]::Create(($lines[0..($count - 1)] -join [char]10))) } + catch { $failed = $true } + if ($failed -ne $conflict) { throw "unexpected initialization failure at $cut" } + if ($conflict) { + if ([IO.File]::ReadAllText((Join-Path $configDir 'real-data')) -ne 'keep') { throw 'overwrote existing configuration' } + } elseif ($cut -lt $lines.Count) { + if (Test-Path $configDir) { throw "exposed partially initialized directory after statement $cut" } + } else { + if ([IO.File]::ReadAllText($dataOwnerMarker) -ne $suffix -or + [IO.File]::ReadAllText($failureDataMarker) -ne $suffix) { throw 'published incomplete ownership evidence' } + } +} +` + dir := t.TempDir() + path := filepath.Join(dir, "recovery-test.ps1") + if err := os.WriteFile(path, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + recovery, err := filepath.Abs("../../.github/scripts/windows-smoke-recovery.ps1") + if err != nil { + t.Fatal(err) + } + command := exec.Command(pwsh, "-NoLogo", "-NoProfile", "-File", path) + command.Env = append(os.Environ(), "RECOVERY_SCRIPT="+recovery, + "INSTALLER_SCRIPT="+filepath.Join(filepath.Dir(recovery), "release-windows-installer-smoke.ps1"), "TEST_ROOT="+dir) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("Windows smoke recovery: %v\n%s", err, output) + } +} diff --git a/internal/webui/workflow_database_shards_test.go b/internal/webui/workflow_database_shards_test.go new file mode 100644 index 00000000..0e552847 --- /dev/null +++ b/internal/webui/workflow_database_shards_test.go @@ -0,0 +1,150 @@ +package webui + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestDatabaseShardsCoverEveryExternalTestExactlyOnce(t *testing.T) { + script := readRepositoryFile(t, ".github/scripts/ci-database-contract.sh") + dir := t.TempDir() + path := filepath.Join(dir, "contract.sh") + if err := os.WriteFile(path, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "go"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o700); err != nil { + t.Fatal(err) + } + type selection struct{ run, skip *regexp.Regexp } + filters := make(map[string]selection) + for _, shard := range []string{"all", "mysql-migrations", "mysql-rest"} { + command := exec.Command("bash", path, shard) + command.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH")) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("%s: %v: %s", shard, err, output) + } + args := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, pkg := range []string{"storage", "control", "requestlog"} { + if !strings.Contains(string(output), "./internal/"+pkg+"\n") { + t.Fatalf("%s omits the %s package", shard, pkg) + } + } + var run, skip string + for i := 0; i+1 < len(args); i++ { + if args[i] == "-run" { + run = args[i+1] + } + if args[i] == "-skip" { + skip = args[i+1] + } + } + if run == "" || skip == "" || !strings.Contains(string(output), "-count=1\n") { + t.Fatalf("%s has incomplete test arguments: %s", shard, output) + } + filters[shard] = selection{regexp.MustCompile(run), regexp.MustCompile(skip)} + } + // 从源码发现测试,新增外部数据库测试也必须被分组覆盖。 + names := []string{"TestExternalFutureContract"} + for _, pkg := range []string{"storage", "control", "requestlog"} { + files, err := filepath.Glob(filepath.Join("..", pkg, "*_test.go")) + if err != nil { + t.Fatal(err) + } + for _, name := range files { + file, err := parser.ParseFile(token.NewFileSet(), name, nil, 0) + if err != nil { + t.Fatal(err) + } + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && strings.HasPrefix(fn.Name.Name, "TestExternal") { + names = append(names, fn.Name.Name) + } + } + } + } + for _, name := range names { + count := 0 + for _, shard := range []string{"mysql-migrations", "mysql-rest"} { + filter := filters[shard] + if filter.run.MatchString(name) && !filter.skip.MatchString(name) { + count++ + } + } + if count != 1 || !filters["all"].run.MatchString(name) || filters["all"].skip.MatchString(name) { + t.Errorf("%s is not covered exactly once for each driver", name) + } + } +} + +func TestMySQLShardsRunConcurrentlyAndPropagateFailures(t *testing.T) { + for _, fail := range []string{"", "migrations", "rest"} { + t.Run("failure="+fail, func(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "contract.sh") + if err := os.WriteFile(script, []byte(readRepositoryFile(t, ".github/scripts/ci-database-contract.sh")), 0o600); err != nil { + t.Fatal(err) + } + fakeGo := `#!/bin/bash +case "$*" in + *"-skip ^TestExternal("*) shard=rest ;; + *) shard=migrations ;; +esac +printf '%s' "$GPT_LOAD_DATABASE_TEST_DSN" > "$MARKERS/$shard" +for ((i=0; i<100; i++)); do + if [[ -f "$MARKERS/rest" && -f "$MARKERS/migrations" ]]; then + [[ "$FAIL_SHARD" != "$shard" ]] + exit $? + fi + sleep 0.01 +done +exit 91 +` + if err := os.WriteFile(filepath.Join(dir, "go"), []byte(fakeGo), 0o700); err != nil { + t.Fatal(err) + } + command := exec.Command("bash", script, "mysql") + command.Env = append(os.Environ(), + "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), + "MARKERS="+dir, "FAIL_SHARD="+fail, "RUNNER_TEMP="+dir, + "GPT_LOAD_DATABASE_TEST_DSN=primary", "GPT_LOAD_DATABASE_SECONDARY_DSN=secondary") + output, err := command.CombinedOutput() + if (err != nil) != (fail != "") { + t.Fatalf("mysql result = %v, failing shard = %q: %s", err, fail, output) + } + for shard, want := range map[string]string{"migrations": "primary", "rest": "secondary"} { + got, err := os.ReadFile(filepath.Join(dir, shard)) + if err != nil || string(got) != want { + t.Fatalf("%s did not use its isolated database: %s, %v", shard, got, err) + } + } + }) + } +} + +func TestDatabaseMatrixStartsOnlyRequiredServers(t *testing.T) { + for _, workflow := range []string{"ci.yml", "release.yml"} { + job := workflowJobBlock(t, readRepositoryFile(t, ".github/workflows/"+workflow), "database-contract") + for _, required := range []string{ + "max-parallel: 2", "secondary-mysql:", "matrix.driver == 'mysql'", + "image: ${{ matrix.image }}", ".github/scripts/ci-database-contract.sh", "GPT_LOAD_DATABASE_SECONDARY_DSN", + "data-dir: /var/lib/mysql", "data-dir: /var/lib/postgresql", + "--tmpfs ${{ matrix.data-dir }}:rw,nosuid,nodev,size=2g", + "--tmpfs /var/lib/mysql:rw,nosuid,nodev,size=2g", + } { + if !strings.Contains(job, required) { + t.Errorf("%s database matrix is missing %s", workflow, required) + } + } + if strings.Contains(job, " mysql:") || strings.Contains(job, " postgres:") { + t.Errorf("%s still unconditionally starts both database types", workflow) + } + } +} diff --git a/internal/webui/workflow_docker_port_test.go b/internal/webui/workflow_docker_port_test.go new file mode 100644 index 00000000..c209bdb0 --- /dev/null +++ b/internal/webui/workflow_docker_port_test.go @@ -0,0 +1,144 @@ +package webui + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestDockerSmokeCancellationCleansOnlyOwnedResources(t *testing.T) { + source := readRepositoryFile(t, ".github/scripts/release-docker-smoke.sh") + preflight, _, found := strings.Cut(source, "cat >\"${task_tmp}/fake-response.json\"") + if !found { + t.Fatal("missing Docker smoke work boundary") + } + for _, conflict := range []string{"false", "true"} { + t.Run(conflict, func(t *testing.T) { + dir := t.TempDir() + log := filepath.Join(dir, "cleanup.log") + script := `docker() { + if [[ "$2" == inspect ]]; then [[ "$CONFLICT" == true ]]; return; fi + printf '%s\n' "$*" >> "$CLEANUP_LOG" +} +` + preflight + "\nkill -s TERM $$\n" + path := filepath.Join(dir, "cancel.sh") + if err := os.WriteFile(path, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + command := exec.Command("bash", path) + command.Env = append(os.Environ(), "CONFLICT="+conflict, "CLEANUP_LOG="+log, "RELEASE_SMOKE_SUFFIX=review-1-2") + output, err := command.CombinedOutput() + var exit *exec.ExitError + wantCode := 143 + if conflict == "true" { + wantCode = 1 + } + if !errors.As(err, &exit) || exit.ExitCode() != wantCode { + t.Fatalf("cancellation exit = %v, want %d: %s", err, wantCode, output) + } + cleanup, err := os.ReadFile(log) + if conflict == "true" { + if !os.IsNotExist(err) { + t.Fatalf("removed pre-existing Docker resources: %s, %v", cleanup, err) + } + return + } + if err != nil { + t.Fatal(err) + } + for _, owned := range []string{"rm -f gpt-load-release-smoke-review-1-2", "volume rm gpt-load-release-smoke-review-1-2", "network rm gpt-load-release-network-review-1-2", "image rm gpt-load-release-smoke:review-1-2"} { + if !strings.Contains(string(cleanup), owned) { + t.Fatalf("cancellation did not clean %s: %s", owned, cleanup) + } + } + }) + } +} + +func TestReleaseDockerSmokeSeparatesRerunResources(t *testing.T) { + workflow := readRepositoryFile(t, ".github/workflows/release.yml") + for _, job := range []string{"docker-smoke", "prebuilt-image-smoke", "post-publish-image-smoke"} { + block := workflowJobBlock(t, workflow, job) + found := false + for _, line := range strings.Split(block, "\n") { + if !strings.HasPrefix(strings.TrimSpace(line), "RELEASE_SMOKE_SUFFIX:") { + continue + } + found = true + if !strings.Contains(line, "${{ github.run_id }}") || !strings.Contains(line, "${{ github.run_attempt }}") { + t.Fatalf("%s reuses Docker resource names across attempts: %s", job, line) + } + } + if !found { + t.Fatalf("%s has no task-specific Docker resource suffix", job) + } + } +} + +func TestDockerSmokeDiscoversLoopbackPortOnEveryContainerStart(t *testing.T) { + source := readRepositoryFile(t, ".github/scripts/release-docker-smoke.sh") + start := strings.Index(source, "start_container() {\n") + if start < 0 { + t.Fatal("missing container startup function") + } + end := strings.Index(source[start:], "\n}\n") + if end < 0 { + t.Fatal("unterminated container startup function") + } + var initialization string + for _, line := range strings.Split(source[:start], "\n") { + if strings.HasPrefix(line, "app_port=") || strings.HasPrefix(line, "base_url=") { + initialization += line + "\n" + } + } + for _, test := range []struct{ name, requested, binding, wantFirst, wantSecond string }{ + {"dynamic", "", "", "http://127.0.0.1:47001", "http://127.0.0.1:47002"}, + {"explicit", "40123", "", "http://127.0.0.1:40123", "http://127.0.0.1:40123"}, + {"unexpected binding", "", "0.0.0.0:47001", "", ""}, + } { + t.Run(test.name, func(t *testing.T) { + script := `set -euo pipefail +container=container +network=network +volume=volume +image=image +starts=0 +docker() { + if [[ "$1" == run ]]; then + starts=$((starts + 1)) + [[ " $* " == *" --publish 127.0.0.1:${RELEASE_SMOKE_APP_PORT:-0}:3001 "* ]] + elif [[ "$1" == port && "$2" == "$container" && "$3" == 3001/tcp ]]; then + if [[ -n "$TEST_BINDING" ]]; then + printf '%s\n' "$TEST_BINDING" + elif [[ -n "$RELEASE_SMOKE_APP_PORT" ]]; then + printf '127.0.0.1:%s\n' "$RELEASE_SMOKE_APP_PORT" + else + printf '127.0.0.1:%s\n' "$((47000 + starts))" + fi + else + return 1 + fi +} +` + initialization + source[start:start+end+3] + ` +start_container +[[ "$base_url" == "$TEST_FIRST_URL" ]] +start_container +[[ "$base_url" == "$TEST_SECOND_URL" ]] +` + path := filepath.Join(t.TempDir(), "port-test.sh") + if err := os.WriteFile(path, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + command := exec.Command("bash", path) + command.Env = append(os.Environ(), "RELEASE_SMOKE_APP_PORT="+test.requested, + "TEST_BINDING="+test.binding, "TEST_FIRST_URL="+test.wantFirst, "TEST_SECOND_URL="+test.wantSecond) + output, err := command.CombinedOutput() + if (err != nil) != (test.binding != "") { + t.Fatalf("port discovery failed: %v: %s", err, output) + } + }) + } +} diff --git a/internal/webui/workflow_self_hosted_test.go b/internal/webui/workflow_self_hosted_test.go new file mode 100644 index 00000000..dda6c0c3 --- /dev/null +++ b/internal/webui/workflow_self_hosted_test.go @@ -0,0 +1,77 @@ +package webui + +import ( + "strings" + "testing" +) + +func TestSelfHostedCIKeepsPlatformGatesAndLocalCaches(t *testing.T) { + ci := readRepositoryFile(t, ".github/workflows/ci.yml") + for job, runner := range map[string]string{ + "test": "[self-hosted, macOS, ARM64]", + "race-tests": "[self-hosted, macOS, ARM64]", + "race-cpa": "[self-hosted, Linux, ARM64]", + "database-contract": "[self-hosted, Linux, ARM64]", + "windows-encryption-acl": "[self-hosted, Windows, X64]", + } { + block := workflowJobBlock(t, ci, job) + if !strings.Contains(block, "runs-on: "+runner) { + t.Errorf("%s is not assigned to %s", job, runner) + } + } + for _, file := range []string{"ci.yml", "release.yml"} { + content := readRepositoryFile(t, ".github/workflows/"+file) + if strings.Contains(content, "cache: true") || strings.Contains(content, ".go-cache-scope") { + t.Errorf("%s still restores remote Go caches over persistent local caches", file) + } + database := workflowJobBlock(t, content, "database-contract") + for _, fixedPort := range []string{"3306:3306", "5432:5432"} { + if strings.Contains(database, fixedPort) { + t.Errorf("%s database gate can collide with local services: %s", file, fixedPort) + } + } + if !strings.Contains(database, "job.services.database.ports[format('{0}', matrix.port)]") { + t.Errorf("%s database DSN does not use the assigned service port", file) + } + } +} + +func TestSelfHostedReleaseIsolatesDockerCredentials(t *testing.T) { + content := readRepositoryFile(t, ".github/workflows/release.yml") + for _, job := range []string{ + "docker-smoke", "prebuilt-image-smoke", "publication-preflight", "publish-images", + "post-publish-image-smoke", "post-publish-verify", "promote-image-channels", "reconcile-publication", + } { + block := workflowJobBlock(t, content, job) + setup := workflowStepBlock(t, block, "Isolate Docker credentials") + if !strings.Contains(setup, `mktemp -d "${RUNNER_TEMP}/docker-config.XXXXXX"`) || + !strings.Contains(setup, `echo "DOCKER_CONFIG=${docker_config}" >> "${GITHUB_ENV}"`) { + t.Errorf("%s can overwrite the host Docker credentials", job) + } + } + build := workflowJobBlock(t, content, "build-binaries") + if !strings.Contains(build, "runs-on: ${{ matrix.runner }}") || + !strings.Contains(workflowStepBlock(t, build, "Build release binary"), "shell: bash") { + t.Fatal("cross-platform binary build must select its runner and use an explicit bash shell") + } +} + +func TestReleaseKeepsX64OnlyForNativeAMD64Validation(t *testing.T) { + content := readRepositoryFile(t, ".github/workflows/release.yml") + const x64 = "[self-hosted, Linux, X64]" + if strings.Count(content, x64) != 2 { + t.Fatal("Linux X64 must only run the native binary and prebuilt image AMD64 gates") + } + for _, job := range []string{"native-artifact-smoke", "prebuilt-image-smoke"} { + block := workflowJobBlock(t, content, job) + if !strings.Contains(block, x64) || !strings.Contains(block, "[self-hosted, Linux, ARM64]") { + t.Errorf("%s must retain both native Linux architectures", job) + } + } + publish := workflowJobBlock(t, content, "publish-images") + qemu := workflowStepBlock(t, publish, "Set up QEMU") + if !strings.Contains(publish, "runs-on: [self-hosted, Linux, ARM64]") || + !strings.Contains(qemu, "platforms: amd64") { + t.Fatal("ARM64 image publisher must enable AMD64 emulation for the other target") + } +} diff --git a/internal/webui/workflow_test.go b/internal/webui/workflow_test.go index 2dfd5e77..e2866aba 100644 --- a/internal/webui/workflow_test.go +++ b/internal/webui/workflow_test.go @@ -244,7 +244,7 @@ func TestBranchAndReleaseWorkflowsRunRaceInParallelGates(t *testing.T) { func TestWindowsCIExecutesManagedStorageACLTests(t *testing.T) { content := readRepositoryFile(t, ".github/workflows/ci.yml") job := workflowJobBlock(t, content, "windows-encryption-acl") - if count := strings.Count(job, "runs-on: windows-2025"); count != 1 { + if count := strings.Count(job, "runs-on: [self-hosted, Windows, X64]"); count != 1 { t.Fatalf("Windows ACL job contains runs-on declaration %d times, want exactly once", count) } assertWorkflowGateStep( @@ -291,7 +291,7 @@ func TestWorkflowsPinExternalActionsAndHostedRunners(t *testing.T) { } ci := readRepositoryFile(t, ".github/workflows/ci.yml") - for _, required := range []string{"runs-on: ubuntu-24.04", "runs-on: windows-2025"} { + for _, required := range []string{"runs-on: [self-hosted, Linux, ARM64]", "runs-on: [self-hosted, macOS, ARM64]", "runs-on: [self-hosted, Windows, X64]"} { if !strings.Contains(ci, required) { t.Errorf("branch CI does not contain %q", required) } @@ -1394,7 +1394,7 @@ func TestReleaseWorkflowDeploysCurrentMajorChannelToRender(t *testing.T) { "RELEASE_VERSION: ${{ needs.validate-tag.outputs.version }}", "IMAGE: ghcr.io/tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}", `RENDER_CLI_VERSION: "2.25.0"`, - `RENDER_CLI_SHA256: "3b3f1f839ef36b81f12d84ac7288f1c96f9f7519b39c53fe6f866612f704e7cd"`, + `RENDER_CLI_SHA256: "7c4dc66ded7acc75e2f828b02d3d6901e9f2e4175d25c4fe5fd6f3aaf66b071b"`, } { if !strings.Contains(job, required) { t.Fatalf("Render deployment job does not contain %q:\n%s", required, job) @@ -1411,7 +1411,7 @@ func TestReleaseWorkflowDeploysCurrentMajorChannelToRender(t *testing.T) { "RENDER_CLI_VERSION", "RENDER_CLI_SHA256", "sha256sum --check", - "cli_${RENDER_CLI_VERSION}_linux_amd64.zip", + "cli_${RENDER_CLI_VERSION}_linux_arm64.zip", "--retry-all-errors", } { if !strings.Contains(install, required) { @@ -2144,11 +2144,11 @@ func TestReleaseWorkflowPostPublishVerifiesDraftAssetsAgainstCurrentRun(t *testi // 发布前的五平台原生 smoke 仍然是必须的门禁。 nativeJob := workflowJobBlock(t, content, "native-artifact-smoke") for _, required := range []string{ - "ubuntu-24.04", - "ubuntu-24.04-arm", + "[self-hosted, Linux, X64]", + "[self-hosted, Linux, ARM64]", "macos-15-intel", - "macos-15", - "windows-2025", + "[self-hosted, macOS, ARM64]", + "[self-hosted, Windows, X64]", "gpt-load-linux-amd64", "gpt-load-linux-arm64", "gpt-load-macos-amd64", diff --git a/third_party/cpaembedded/embedded/codex_websocket_log_test.go b/third_party/cpaembedded/embedded/codex_websocket_log_test.go index 5a604eab..5a131d32 100644 --- a/third_party/cpaembedded/embedded/codex_websocket_log_test.go +++ b/third_party/cpaembedded/embedded/codex_websocket_log_test.go @@ -10,20 +10,27 @@ import ( "strings" "sync" "testing" + "time" "github.com/gorilla/websocket" "github.com/sirupsen/logrus" ) type codexWSLogBuffer struct { - mu sync.Mutex - buf bytes.Buffer + mu sync.Mutex + buf bytes.Buffer + updated chan struct{} } func (b *codexWSLogBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() - return b.buf.Write(p) + n, err := b.buf.Write(p) + select { + case b.updated <- struct{}{}: + default: + } + return n, err } func (b *codexWSLogBuffer) String() string { @@ -38,11 +45,16 @@ func TestCodexWSSessionRedactsCloseReason(t *testing.T) { defer logrus.SetOutput(output) for _, code := range []int{websocket.ClosePolicyViolation, 4001} { t.Run(fmt.Sprint(code), func(t *testing.T) { - var logs codexWSLogBuffer + logs := codexWSLogBuffer{updated: make(chan struct{}, 1)} logrus.SetOutput(&logs) const marker = "private-close-reason-marker" + headersReady := make(chan struct{}) + releaseClose := sync.OnceFunc(func() { close(headersReady) }) + defer releaseClose() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + conn, err := (&websocket.Upgrader{}).Upgrade(w, r, http.Header{ + "X-Request-Id": {"close-reason-redaction-test"}, + }) if err != nil { t.Error(err) return @@ -51,21 +63,43 @@ func TestCodexWSSessionRedactsCloseReason(t *testing.T) { if _, _, err := conn.ReadMessage(); err != nil { return } + <-headersReady if err := conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(code, marker)); err != nil { t.Error(err) } })) defer server.Close() session := wsTestSession(t, server.URL) + headersObserved := false + session.options.ObserveHeaders = func(http.Header, time.Time) { + headersObserved = true + releaseClose() + // SDK 先交付错误再记录断连;若封装先清理连接,SDK 会跳过日志。 + // 在握手交接处控制这个合法时序,确保本用例实际经过日志脱敏路径。 + timeout := time.NewTimer(time.Second) + defer timeout.Stop() + for !strings.Contains(logs.String(), "upstream disconnected session="+session.id+" ") { + select { + case <-logs.updated: + case <-timeout.C: + t.Errorf("missing SDK close diagnostics before cleanup: %s", logs.String()) + return + } + } + } _, err := session.ExecuteTurn(context.Background(), json.RawMessage(`{"model":"gpt-5","input":"hello"}`), nil) if err == nil { t.Fatal("upstream close must fail the turn") } - if strings.Contains(logs.String(), marker) { + if !headersObserved { + t.Fatal("handshake callback did not exercise the controlled log ordering") + } + output := logs.String() + if strings.Contains(output, marker) { t.Fatal("upstream close reason leaked into default logs") } - if !strings.Contains(logs.String(), fmt.Sprintf("ws_close_code=%d", code)) || !strings.Contains(logs.String(), "error_class=websocket_closed") { - t.Fatalf("missing safe close diagnostics: %s", logs.String()) + if !strings.Contains(output, fmt.Sprintf("ws_close_code=%d", code)) || !strings.Contains(output, "error_class=websocket_closed") { + t.Fatalf("missing safe close diagnostics: %s", output) } }) } diff --git a/third_party/cpaembedded/embedded/codex_websocket_test.go b/third_party/cpaembedded/embedded/codex_websocket_test.go index 77771578..6f317203 100644 --- a/third_party/cpaembedded/embedded/codex_websocket_test.go +++ b/third_party/cpaembedded/embedded/codex_websocket_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "sync/atomic" "testing" "time" @@ -505,13 +506,23 @@ func (lifecycle *closeFirstWSBinding) End(reason string) { lifecycle.resource.En func TestCodexWSSessionGuardsSDKSendRetry(t *testing.T) { var frames, handshakes atomic.Int32 + var handlersMu sync.Mutex + var handlers []chan struct{} + recordAfterSend := make(chan struct{}) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + done := make(chan struct{}) + handlersMu.Lock() + handlers = append(handlers, done) + handlersMu.Unlock() + defer close(done) conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) if err != nil { t.Error(err) return } defer conn.Close() + // 握手响应已发出,但服务端统计尚未更新,复现客户端先返回的调度顺序。 + <-recordAfterSend handshakes.Add(1) if _, _, err := conn.ReadMessage(); err == nil { frames.Add(1) @@ -529,11 +540,27 @@ func TestCodexWSSessionGuardsSDKSendRetry(t *testing.T) { Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: session.id}, ExecutionLifecycle: lifecycle, }) + // 请求结束后立即撤销上下文,收尾统计仍必须有自己的等待时间。 + cancel() + close(recordAfterSend) + waitCtx, cancelWait := context.WithTimeout(context.Background(), time.Second) + defer cancelWait() if err == nil { t.Fatal("failed send succeeded") } + // 客户端返回不代表服务端已统计完连接和业务帧;等待真实处理结束再断言。 + handlersMu.Lock() + pendingHandlers := append([]chan struct{}(nil), handlers...) + handlersMu.Unlock() + for _, done := range pendingHandlers { + select { + case <-done: + case <-waitCtx.Done(): + t.Fatal("server handlers did not finish after SDK send failure") + } + } if frames.Load() != 0 || handshakes.Load() < 1 || handshakes.Load() > 2 { - t.Fatal("SDK send failure escaped the lifecycle guard") + t.Fatalf("SDK send failure escaped the lifecycle guard: frames=%d handshakes=%d binds=%d", frames.Load(), handshakes.Load(), lifecycle.binds.Load()) } if lifecycle.binds.Load() < 1 || lifecycle.binds.Load() > 2 { t.Fatal("unexpected SDK binding attempts")