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:
1 parent
9d38793dd0
commit
d01656a1d8
30 files changed
+1115
-161
No files matched your search
@@ -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
|
||||
|
||||
Executable
+46
@@ -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}"
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+49
-41
@@ -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: |
|
||||
|
||||
+136
-69
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,6 +3,7 @@ package migrations
|
||||
import "testing"
|
||||
|
||||
func TestMySQLRequiresCheckDropSyntax0003(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
serverVersion string
|
||||
want bool
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+28
-1
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user