ci: 配置 master 自动验证与服务器部署

This commit is contained in:
2026-10-09 20:42:31 +08:00
parent 57882ff568
commit 2cad6d7533
14 changed files with 504 additions and 9 deletions
+41
View File
@@ -0,0 +1,41 @@
# Veyra 自动部署
开发在 `dev` 分支进行,不直接提交或推送 `master`。Gitea 中的 `master` 保护规则禁用直接推送、强制推送和管理员绕过;通过合并请求进入 `master`。流水线只监听 `master` 的 push,合并提交也会触发;不监听 `dev` 或 PR 事件。因此合并前由本地检查把关,流水线在合并后验证,验证失败不部署。
## 当前部署目标
- Gitea:`https://git.sundynix.cn/Blizzard/Veyra`,使用已有全局 Runner 的 `ubuntu-latest` 标签。
- SSH:`root@192.168.100.132`;目录:`/home/workspace/sundynix-veyra`。
- 默认管理端与 API:`http://192.168.100.132:8080`,同一个镜像、同源服务。
- 应用配置:服务器 `configs/.env`,权限 600;日志:服务器 `logs/`,由容器 UID 10001 写入并轮转。
- PostgreSQL、Redis 使用已有内网服务;不额外部署数据库容器,不覆盖数据、身份或加密主密钥。
仓库 Actions 已启用,部署使用 `DEPLOY_PASSWORD`、`DEPLOY_KNOWN_HOSTS` 两个仓库 Secret。服务器地址、用户和目录在工作流中明确配置;SSH 严格校验已知服务器公钥。可选 `VEYRA_ENV_FILE` 仅用于新服务器首次创建配置,已有文件永不覆盖。当前服务器已通过 SSH 安全初始化,无需设置此可选 Secret。
应用环境文件必须采用无外层引号的 `KEY=VALUE` 格式。Compose 使用 `env_file.format: raw`,保留密码中的 `$`、`#` 等字符,不把应用配置作为 Compose 插值文件。需要 Docker、Compose >=2.30、bash、flock、gzip、sha256sum;当前服务器 Compose 2.36.2 满足要求。
## 流水线
1. 使用 Gitea job token 下载当前提交归档,与参考项目保持一致,避免 Git HTTP pack 检出问题。
2. Docker 内检查 App 类型、12 项客户端测试、iOS/Android Hermes 导出;管理端类型检查、构建、Go embed;gofmt、go vet 与 Go race 测试。构建阶段使用已验证可达的 `https://goproxy.cn,direct` 下载 Go 依赖,保留默认 checksum 校验;运行镜像不包含此构建设置。PostgreSQL 集成测试使用独立、用后清理的 CI 容器,不访问开发库。部署脚本也有成功、校验失败、迁移失败、健康失败回滚、配置保留测试。
3. 所有检查通过才构建 `sundynix/veyra:<完整提交 SHA>`,打包压缩并生成 SHA-256 校验文件,通过 SSH 传入服务器的独立发布目录。
4. 服务器加锁串行发布,校验压缩包、加载镜像,执行同一可执行文件的 `-command db-create` 和 `-command db-migrate`,再启动 Compose。
5. `/readyz` 检查 PostgreSQL 与 Redis。新版本就绪后才更新 `.current-image`;失败则恢复上一版镜像与 Compose,首次部署失败则停止应用。迁移失败不替换运行中的服务。不会自动回退数据库迁移;后续迁移应保持与上一版应用兼容,破坏性迁移需单独设计。
回测 Python 引擎和管理端已包含在运行镜像中;App 导出仅用于 CI 检查,不是 TestFlight、IPA 发布或 Expo OTA 更新。
目前认证与行情、交易研究仍处于开发阶段,服务器沿用本地 `APP_ENV=local` 配置以支持现有开发身份。此部署不代表生产认证、安全与正式行情授权已完成;改为生产环境前需完成正式登录流程。
## 本地检查
```sh
bash deploy/check.sh # 需要运行中的 Docker;完成容器内全部检查
python3 deploy/tests/test_release.py # 无需 Docker,检查发布决策与配置保留
npm --prefix web/admin run build
go vet ./...
go build -trimpath -o bin/veyra .
```
服务器排查使用 `docker compose --env-file /dev/null -f docker-compose.yml`,同时将 `VEYRA_IMAGE` 设为 `.current-image` 中的值。不要将含凭据的 `docker compose config`、环境文件或容器完整 inspect 输出到日志;配置语法检查使用 `config --quiet`。
配置语义参考 [Gitea Actions](https://docs.gitea.com/usage/actions/) 与 [Docker Compose env_file](https://docs.docker.com/reference/compose-file/services/#env_file)。
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
root="${1:?deployment directory required}"
[[ "$root" == /* && "$root" != / && "$root" != *[!a-zA-Z0-9_/-]* ]] || exit 2
umask 077
mkdir -p "$root/configs"
tmp="$(mktemp "$root/configs/.env.XXXXXX")"
trap 'rm -f "$tmp"' EXIT
cat > "$tmp"
[[ -s "$tmp" ]] || exit 1
# Atomic create-if-absent. Existing configuration and encryption key win.
ln "$tmp" "$root/configs/.env" 2>/dev/null || test -s "$root/configs/.env"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
run_id="veyra-ci-$(date +%s)-${RANDOM}"
network="$run_id"
postgres="$run_id-postgres"
cleanup() {
docker rm -f "$postgres" >/dev/null 2>&1 || true
docker network rm "$network" >/dev/null 2>&1 || true
docker image rm "veyra-mobile-check:$run_id" "veyra-test:$run_id" >/dev/null 2>&1 || true
}
trap cleanup EXIT
docker build --target mobile-check -t "veyra-mobile-check:$run_id" .
docker build --target test -t "veyra-test:$run_id" .
docker run --rm --entrypoint python3 "veyra-test:$run_id" /src/deploy/tests/test_release.py
docker network create "$network" >/dev/null
docker run -d --name "$postgres" --network "$network" --network-alias postgres \
-e POSTGRES_USER=veyra_test -e POSTGRES_PASSWORD=test_only -e POSTGRES_DB=veyra_test postgres:17 >/dev/null
ready=false
for ((i=0; i<60; i++)); do
if docker exec "$postgres" pg_isready -U veyra_test -d veyra_test >/dev/null 2>&1; then ready=true; break; fi
sleep 1
done
if [[ "$ready" != true ]]; then echo 'CI PostgreSQL did not become ready' >&2; exit 1; fi
docker run --rm --network "$network" \
-e VEYRA_TEST_DATABASE_URL='postgres://veyra_test:test_only@postgres:5432/veyra_test?sslmode=disable' \
"veyra-test:$run_id"
+25
View File
@@ -0,0 +1,25 @@
name: veyra
services:
app:
image: ${VEYRA_IMAGE:?VEYRA_IMAGE is required}
restart: unless-stopped
init: true
env_file:
- path: ./configs/.env
format: raw
environment:
HTTP_ADDR: 0.0.0.0:8080
LOG_DIR: logs
PYTHON_EXECUTABLE: python3
ports:
- "${VEYRA_BIND_ADDR:-0.0.0.0}:${VEYRA_PORT:-8080}:8080"
volumes:
- ./logs:/app/logs
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
stop_grace_period: 30s
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Run on the deployment host: release.sh /home/workspace/veyra <commit SHA>
set -euo pipefail
root="${1:?deployment directory required}"
sha="${2:?commit SHA required}"
[[ "$root" == /* && "$root" != / && "$root" != *[!a-zA-Z0-9_/-]* ]] || exit 2
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || exit 2
release="$root/releases/$sha"
image="sundynix/veyra:$sha"
cd "$root"
# Serialize releases even when several master commits queue up.
exec 9> .deploy.lock
flock -w 600 9
if [[ ! -s configs/.env ]]; then echo 'Missing server configs/.env (or VEYRA_ENV_FILE bootstrap secret)' >&2; exit 1; fi
(cd "$release" && sha256sum -c image.tar.gz.sha256)
gunzip -c "$release/image.tar.gz" | docker load
mkdir -p logs
docker run --rm --user 0:0 --entrypoint sh -v "$root/logs:/app/logs" "$image" -c 'chown 10001:10001 /app/logs'
previous=''
if [[ -s .current-image ]]; then
previous="$(cat .current-image)"
[[ "$previous" =~ ^sundynix/veyra:[0-9a-f]{40}$ ]] || exit 2
fi
# Keep the previous Compose file as well as the previously healthy image.
if [[ -f docker-compose.yml ]]; then cp docker-compose.yml "$release/previous-compose.yml"; fi
cp "$release/docker-compose.yml" docker-compose.yml
export VEYRA_IMAGE="$image"
# Use an empty interpolation file: application secrets are only read via raw env_file.
compose() { docker compose --env-file /dev/null -f "$root/docker-compose.yml" "$@"; }
if ! compose run --rm --no-deps app -command db-create || ! compose run --rm --no-deps app -command db-migrate; then
if [[ -f "$release/previous-compose.yml" ]]; then cp "$release/previous-compose.yml" docker-compose.yml; fi
echo 'Database preparation failed; existing application was not replaced' >&2
exit 1
fi
if compose up -d --no-build --wait --wait-timeout 120; then
printf '%s\n' "$image" > .current-image.tmp
mv .current-image.tmp .current-image
rm -f "$release/image.tar.gz"
echo "Veyra deployed: $sha"
else
echo 'New application failed readiness; restoring previous application' >&2
if [[ -n "$previous" && -f "$release/previous-compose.yml" ]]; then
cp "$release/previous-compose.yml" docker-compose.yml
export VEYRA_IMAGE="$previous"
compose up -d --no-build --wait --wait-timeout 120
else
compose stop app
fi
# Schema migrations are never automatically undone.
exit 1
fi
+112
View File
@@ -0,0 +1,112 @@
"""Verify deployment decisions without connecting to a server or Docker daemon."""
import gzip
import hashlib
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
SHA = "a" * 40
OLD = "sundynix/veyra:" + "b" * 40
class ReleaseTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="veyra-release-")
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
(self.root / "configs").mkdir()
(self.root / "configs/.env").write_text("TEST_SETTING=unchanged\n")
self.release = self.root / "releases" / SHA
self.release.mkdir(parents=True)
archive = gzip.compress(b"fake image")
(self.release / "image.tar.gz").write_bytes(archive)
digest = hashlib.sha256(archive).hexdigest()
(self.release / "image.tar.gz.sha256").write_text(digest + " image.tar.gz\n")
(self.release / "docker-compose.yml").write_text("new compose\n")
(self.root / "docker-compose.yml").write_text("old compose\n")
(self.root / ".current-image").write_text(OLD + "\n")
tools = self.root / "tools"
tools.mkdir()
mock = tools / "docker"
mock.write_text(r"""#!/usr/bin/env python3
import os, sys
from pathlib import Path
args = sys.argv[1:]
with open(os.environ['MOCK_LOG'], 'a') as log:
log.write(os.environ.get('VEYRA_IMAGE', '') + ' ' + ' '.join(args) + '\n')
if args and args[0] == 'load':
sys.stdin.buffer.read()
if '-command' in args and args[-1] == 'db-migrate' and os.environ.get('FAIL_MIGRATION'):
sys.exit(1)
if 'up' in args and os.environ.get('FAIL_READINESS') and os.environ.get('VEYRA_IMAGE') != os.environ['OLD_IMAGE']:
sys.exit(1)
""")
mock.chmod(0o755)
# macOS has no flock; emulate it for this single-process decision test.
if not __import__('shutil').which('flock'):
(tools / 'flock').write_text('#!/bin/sh\nexit 0\n')
(tools / 'flock').chmod(0o755)
if not __import__('shutil').which('sha256sum'):
checksum = tools / 'sha256sum'
checksum.write_text("""#!/usr/bin/env python3
import hashlib, sys
from pathlib import Path
for line in Path(sys.argv[2]).read_text().splitlines():
digest, name = line.split(' ', 1)
if hashlib.sha256(Path(name).read_bytes()).hexdigest() != digest:
sys.exit(1)
""")
checksum.chmod(0o755)
self.env = dict(os.environ, PATH=str(tools) + os.pathsep + os.environ['PATH'],
MOCK_LOG=str(self.root / 'commands'), OLD_IMAGE=OLD)
def run_release(self, **flags):
return subprocess.run(['bash', str(SCRIPTS / 'release.sh'), str(self.root), SHA],
env=dict(self.env, **flags), capture_output=True, text=True)
def test_success_promotes_exact_commit(self):
result = self.run_release()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.root / '.current-image').read_text().strip(), 'sundynix/veyra:' + SHA)
self.assertEqual((self.root / 'configs/.env').read_text(), 'TEST_SETTING=unchanged\n')
self.assertFalse((self.release / 'image.tar.gz').exists())
calls = (self.root / 'commands').read_text()
self.assertLess(calls.index('db-migrate'), calls.index(' up '))
def test_failed_readiness_restores_previous_application(self):
self.assertNotEqual(self.run_release(FAIL_READINESS='1').returncode, 0)
self.assertEqual((self.root / '.current-image').read_text().strip(), OLD)
self.assertEqual((self.root / 'docker-compose.yml').read_text(), 'old compose\n')
self.assertIn(OLD + ' compose', (self.root / 'commands').read_text())
def test_failed_migration_does_not_replace_running_application(self):
self.assertNotEqual(self.run_release(FAIL_MIGRATION='1').returncode, 0)
self.assertNotIn(' up ', (self.root / 'commands').read_text())
self.assertEqual((self.root / 'docker-compose.yml').read_text(), 'old compose\n')
def test_invalid_archive_never_loads_image(self):
(self.release / 'image.tar.gz').write_bytes(b'corrupt')
self.assertNotEqual(self.run_release().returncode, 0)
self.assertFalse((self.root / 'commands').exists())
def test_missing_configuration_fails_before_image_load(self):
(self.root / 'configs/.env').unlink()
self.assertNotEqual(self.run_release().returncode, 0)
self.assertFalse((self.root / 'commands').exists())
def test_bootstrap_preserves_existing_configuration(self):
for _ in range(2):
result = subprocess.run(['bash', str(SCRIPTS / 'bootstrap-config.sh'), str(self.root)],
input='TEST_SETTING=new\n', capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.root / 'configs/.env').read_text(), 'TEST_SETTING=unchanged\n')
(self.root / 'configs/.env').unlink()
result = subprocess.run(['bash', str(SCRIPTS / 'bootstrap-config.sh'), str(self.root)],
input='TEST_SETTING=new\n', capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.root / 'configs/.env').stat().st_mode & 0o777, 0o600)
if __name__ == '__main__':
unittest.main()