perf(ci): 迁移自托管运行器并优化并行验收 (#634)

* perf(ci): 切换现有自托管运行器并复用本机缓存

* perf(ci): 将主要 Linux 任务迁至 Mac ARM64 运行器

* fix(ci): 使用动态端口执行 Windows 安装验收

* test(cpa): 等待异步处理完成后校验 WebSocket 行为

* perf(ci): 并行数据库测试并隔离容器验收端口

* ci: 为数据库合同测试启用内存盘

* test(cpa): 固定断连日志脱敏测试的执行顺序

* fix(ci): 修复自托管验收中断和架构兼容问题

* fix(ci): 原子准备 Windows 安装验收配置
This commit is contained in:
tbphp authored and GitHub committed 2026-09-13 08:31:56 +08:00
1 parent 9d38793dd0
commit d01656a1d8
30 files changed
+1115 -161

No files matched your search

+46
View File
@@ -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}"
+19 -8
View File
@@ -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()
}
+9 -3
View File
@@ -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() {
@@ -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()
}
@@ -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
}
}