Compare commits
4 Commits
5a7f39726b
..
prod
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bfc787691 | |||
| a1b160e1b0 | |||
| 79b432c3d0 | |||
| a3cb6dbacf |
@@ -1,82 +1,23 @@
|
|||||||
name: Deploy production
|
name: Deploy production
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [prod]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
# Build on the runner; the production Docker daemon is reached only over SSH.
|
# Manual runs must also select prod before receiving production credentials.
|
||||||
runs-on: ubuntu-latest
|
if: ${{ github.ref == 'refs/heads/prod' }}
|
||||||
env:
|
# Match the label of tencent-prod-runner on server B, not its runner name.
|
||||||
IMAGE_PREFIX: ${{ vars.IMAGE_PREFIX }}
|
runs-on: tencent-prod
|
||||||
DEPLOY_TAG: ${{ gitea.sha }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v4
|
uses: https://github.com/actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up QEMU for cross-platform builds
|
- name: Build, migrate and deploy
|
||||||
uses: https://github.com/docker/setup-qemu-action@v3
|
|
||||||
with:
|
|
||||||
platforms: amd64,arm64
|
|
||||||
# Gitea does not use the GitHub Actions cache backend.
|
|
||||||
cache-image: false
|
|
||||||
- name: Build and push to Gitea Packages
|
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
BUILD_PLATFORM: ${{ vars.BUILD_PLATFORM }}
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
umask 077
|
|
||||||
: "${IMAGE_PREFIX:?Set IMAGE_PREFIX to registry/owner/image (without a scheme or tag)}"
|
|
||||||
: "${DEPLOY_TAG:?Missing commit SHA}"
|
|
||||||
: "${REGISTRY_USERNAME:?Set REGISTRY_USERNAME}"
|
|
||||||
: "${REGISTRY_PASSWORD:?Set REGISTRY_PASSWORD}"
|
|
||||||
registry="${IMAGE_PREFIX%%/*}"
|
|
||||||
auth_dir=$(mktemp -d)
|
|
||||||
builder_name=""
|
|
||||||
cleanup() {
|
|
||||||
local rc=$?
|
|
||||||
if [[ -n "$builder_name" ]]; then
|
|
||||||
docker buildx rm "$builder_name" >/dev/null || true
|
|
||||||
fi
|
|
||||||
rm -rf -- "$auth_dir"
|
|
||||||
exit "$rc"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
trap 'exit 130' INT
|
|
||||||
trap 'exit 143' TERM
|
|
||||||
export DOCKER_CONFIG="$auth_dir"
|
|
||||||
export BUILDX_CONFIG="$auth_dir/buildx"
|
|
||||||
printf '%s' "$REGISTRY_PASSWORD" | docker login "$registry" --username "$REGISTRY_USERNAME" --password-stdin
|
|
||||||
unset REGISTRY_PASSWORD
|
|
||||||
# Create after selecting the isolated config; do not change the runner's default builder.
|
|
||||||
builder_name=$(docker buildx create --driver docker-container)
|
|
||||||
docker info --format 'Build host: {{.OSType}}/{{.Architecture}}'
|
|
||||||
echo "Target platform: ${BUILD_PLATFORM:-linux/amd64}"
|
|
||||||
docker buildx inspect "$builder_name" --bootstrap
|
|
||||||
docker buildx build --builder "$builder_name" --pull --load --platform "${BUILD_PLATFORM:-linux/amd64}" \
|
|
||||||
--label "org.opencontainers.image.revision=$DEPLOY_TAG" \
|
|
||||||
-f Dockerfile.backend -t "$IMAGE_PREFIX-backend:$DEPLOY_TAG" .
|
|
||||||
docker buildx build --builder "$builder_name" --pull --load --platform "${BUILD_PLATFORM:-linux/amd64}" \
|
|
||||||
--label "org.opencontainers.image.revision=$DEPLOY_TAG" \
|
|
||||||
-f Dockerfile.frontend -t "$IMAGE_PREFIX-web:$DEPLOY_TAG" .
|
|
||||||
docker push "$IMAGE_PREFIX-backend:$DEPLOY_TAG"
|
|
||||||
docker push "$IMAGE_PREFIX-web:$DEPLOY_TAG"
|
|
||||||
- name: Pull, migrate and deploy on server B
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
PROD_HOST: ${{ secrets.PROD_HOST }}
|
|
||||||
PROD_PORT: ${{ secrets.PROD_PORT }}
|
|
||||||
PROD_USER: ${{ secrets.PROD_USER }}
|
|
||||||
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
|
|
||||||
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
|
|
||||||
PROD_KNOWN_HOSTS: ${{ secrets.PROD_KNOWN_HOSTS }}
|
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
|
ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
|
||||||
WQ_EMAIL: ${{ secrets.WQ_EMAIL }}
|
WQ_EMAIL: ${{ secrets.WQ_EMAIL }}
|
||||||
@@ -85,4 +26,4 @@ jobs:
|
|||||||
ADMIN_USERNAME: ${{ vars.ADMIN_USERNAME }}
|
ADMIN_USERNAME: ${{ vars.ADMIN_USERNAME }}
|
||||||
PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }}
|
PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }}
|
||||||
MCP_ENABLED: ${{ vars.MCP_ENABLED }}
|
MCP_ENABLED: ${{ vars.MCP_ENABLED }}
|
||||||
run: bash scripts/deploy-remote.sh
|
run: bash scripts/deploy-production.sh
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
# 两机镜像部署
|
|
||||||
|
|
||||||
Type: task
|
|
||||||
Status: ready-for-agent
|
|
||||||
|
|
||||||
## 要求
|
|
||||||
|
|
||||||
- Runner 构建 backend/web 并推送 A 的 Gitea Packages,两个镜像成功后通过 SSH 部署 B。
|
|
||||||
- B 仅接收 Compose、部署脚本和环境,拉取镜像后预检、互斥迁移及健康启动。
|
|
||||||
- 数据库位于 C,B 通过 `DATABASE_URL` 连接 C 的内网地址,不再依赖 `DATABASE_NETWORK`。
|
|
||||||
- 沿用 Gitea 应用配置及用户指定的 `PROD_SSH_KEY`、`PROD_HOST`、`PROD_USER` Secrets。
|
|
||||||
- 保留原有本地/公网 Compose、数据库及单 worker 限制;更新部署说明。
|
|
||||||
|
|
||||||
## 验收
|
|
||||||
|
|
||||||
校验工作流与 Compose 配置、B 部署脚本的失败分支、SSH 环境特殊字符传输、临时认证文件清理。真实 A 推送及 B 部署不在本地验收中执行。
|
|
||||||
|
|
||||||
## Comments
|
|
||||||
|
|
||||||
- 2026-09-24:已开始实施,真实服务器连接参数由用户在 Gitea 配置。
|
|
||||||
- 2026-09-24:本地实现完成。生产 Compose 改为纯镜像引用;工作流构建推送后通过 SSH 传递部署文件和环境,B 拉取、预检、加锁、迁移并等待健康状态。说明已同步。
|
|
||||||
- 2026-09-24:按用户确认适配 B 应用/C 数据库分离,移除外部数据库网络及 `DATABASE_NETWORK` 的传递、必填校验;说明和测试同步更新。未设置该变量时,Compose 5.3.1 默认/jobs profile 校验通过,backend/migrate/web 均使用默认网络,远程数据库 URL 保持一致;8 个必填配置拒绝、Bash/工作流检查和 8 项模拟部署测试通过。未连接真实 B/C,实际容器到 C 的连通性仍需部署验收。
|
|
||||||
- 2026-09-24:用户提供首个 RUN 的 `/bin/sh: exec format error` 并确认 Runner 为 aarch64、B 为 x86_64。补充 QEMU 初始化(关闭 GitHub 缓存)与独立 docker-container Buildx 构建器;在设置临时 Docker/Buildx 配置后创建并显式选择该构建器,退出时清理。工作流缺少 QEMU 的配置断言先失败,修复后通过;Bash、默认 amd64 构建目标、登录/创建/初始化/前后端构建/推送失败分支及清理退出码模拟检查通过。本机 ARM64 Docker Engine 29.6.2 / Buildx 0.35.0 使用独立构建器、相同 Python 基础镜像 digest 与 uv 0.7.20 成功执行 amd64 shell/Python/uv,导出架构确认为 linux/amd64,临时镜像及构建器已清理。未在远程 Runner 执行 QEMU 注册,未完整重建应用依赖或推送部署。
|
|
||||||
|
|
||||||
## 初次镜像部署验证结果(数据库拆分前)
|
|
||||||
|
|
||||||
- 本机 Docker Compose 5.3.1 默认/jobs profile 展开通过;确认所有服务无 build、backend 与 migrate 共用提交镜像、外部数据库网络及回环端口保留,9 个必填 Compose 配置缺失均拒绝。
|
|
||||||
- Workflow YAML、SSH Secrets 映射及 Bash 语法检查通过;模拟构建步骤验证成功顺序与 login/build/push 失败传播、认证目录清理。
|
|
||||||
- `python3 -m unittest discover -s scripts/tests -v`:8 项通过,覆盖前置失败保护旧服务、锁归属、迁移失败不启动、健康失败返回非零、缺失配置、SSH 特殊字符传递与环境隔离、远端失败传播和临时文件清理。
|
|
||||||
- 独立只读核验完成;停服后失败仍需人工处理,沿用既有迁移策略,避免未知 schema 状态下自动回退。
|
|
||||||
- `git diff --check` 通过。Dockerfile/业务代码未改变,未重建真实镜像;真实 Gitea 推送、B SSH 身份与权限、数据库迁移和公网健康仍需首次运行工作流验收。
|
|
||||||
@@ -1,19 +1,5 @@
|
|||||||
# Gitea 生产部署
|
# Gitea 生产部署
|
||||||
|
|
||||||
## 当前部署方式(2026-09-24)
|
|
||||||
|
|
||||||
本节替代下方历史记录中的同机构建部署方案。A 运行 Gitea/Packages,Runner 构建并推送两个提交标签镜像,通过 SSH 在 B 的独立发布目录执行 pull、预检、互斥迁移及健康检查;生产 Compose 不含 build。现有数据库、项目名、端口和 Gitea 应用配置沿用。
|
|
||||||
|
|
||||||
数据库独立部署在 C,B 的 backend 和 migrate 通过 `DATABASE_URL` 使用 C 的内网地址。生产 Compose 使用默认网络,移除 `DATABASE_NETWORK` 外部网络声明及工作流、部署脚本的传递和必填校验。
|
|
||||||
|
|
||||||
SSH 采用用户指定的 Gitea Secrets:`PROD_SSH_KEY`、`PROD_HOST`、`PROD_USER`,补充主机公钥校验 `PROD_KNOWN_HOSTS` 与可选端口 `PROD_PORT`。镜像前缀、B 部署目录和构建平台可配置。应用环境经 SSH stdin 传递,不生成应用凭据文件;SSH 和 Docker 认证临时目录结束时清理。
|
|
||||||
|
|
||||||
Runner 为 ARM64、B 为 x86_64,目标仍为 `linux/amd64`。工作流先初始化 QEMU,再在临时 Docker/Buildx 配置下创建独立构建器,显式用于两个镜像,结束时清理;QEMU 步骤要求构建机 Docker 允许特权容器注册 binfmt。
|
|
||||||
|
|
||||||
任务与验证见 [两机镜像部署](issues/01-remote-deployment.md)。本任务只修改本地文件及进行隔离验证,不推送镜像或操作真实 B。
|
|
||||||
|
|
||||||
## 历史方案
|
|
||||||
|
|
||||||
采用已选择的 compound 经验 1,经当前项目核验:复用锁定依赖的 Dockerfile、单 worker 和数据库健康接口;新增独立生产 Compose、外部 PostgreSQL 配置、同机 host Runner 工作流。生产凭据通过 Gitea Secrets 注入,非敏感配置通过 Variables 注入,不生成凭据文件。数据库网络为可配置参数,不沿用旧项目常量作为强制约定。
|
采用已选择的 compound 经验 1,经当前项目核验:复用锁定依赖的 Dockerfile、单 worker 和数据库健康接口;新增独立生产 Compose、外部 PostgreSQL 配置、同机 host Runner 工作流。生产凭据通过 Gitea Secrets 注入,非敏感配置通过 Variables 注入,不生成凭据文件。数据库网络为可配置参数,不沿用旧项目常量作为强制约定。
|
||||||
|
|
||||||
部署在构建完成后停止写入、执行一次性迁移,再启动健康检查。固定项目名与按提交标记镜像;使用宿主机文件锁串行化。保留本地和独立公网部署的现有行为。不执行远程部署、不修改知识库。
|
部署在构建完成后停止写入、执行一次性迁移,再启动健康检查。固定项目名与按提交标记镜像;使用宿主机文件锁串行化。保留本地和独立公网部署的现有行为。不执行远程部署、不修改知识库。
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ Chatbox 来源使用 `kind=chatbox`,会话 ID 为 `reference`,生成轮次 I
|
|||||||
|
|
||||||
## Gitea 自动部署(复用已有 PostgreSQL)
|
## Gitea 自动部署(复用已有 PostgreSQL)
|
||||||
|
|
||||||
Gitea Runner 构建镜像并推送到服务器 A 的 Gitea Packages,再通过 SSH 到服务器 B 拉取镜像、迁移和部署;B 上的应用通过 `DATABASE_URL` 连接服务器 C 的内网 PostgreSQL。使用独立的 `compose.production.yaml` 和 `.gitea/workflows/deploy-production.yaml`。镜像仓库、SSH、数据库配置及升级处理见 [Gitea 部署说明](docs/deployment-gitea.md)。
|
使用独立的 `compose.production.yaml` 和 `.gitea/workflows/deploy-production.yaml`。配置步骤、数据库账号密码位置及升级处理见 [Gitea 部署说明](docs/deployment-gitea.md)。
|
||||||
|
|
||||||
## 公网 HTTPS 部署
|
## 公网 HTTPS 部署
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ x-container-logging: &container-logging
|
|||||||
max-file: "5"
|
max-file: "5"
|
||||||
|
|
||||||
x-backend: &backend
|
x-backend: &backend
|
||||||
image: ${IMAGE_PREFIX:?Set the Gitea registry/owner/image prefix}-backend:${DEPLOY_TAG:?Set the published release tag}
|
image: wq-alpha-production-backend:${DEPLOY_TAG:-local}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.backend
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL:?Set the Gitea DATABASE_URL secret}
|
DATABASE_URL: ${DATABASE_URL:?Set the Gitea DATABASE_URL secret}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
@@ -29,6 +32,7 @@ x-backend: &backend
|
|||||||
AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096}
|
AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096}
|
||||||
AI_TIMEOUT: ${AI_TIMEOUT:-180}
|
AI_TIMEOUT: ${AI_TIMEOUT:-180}
|
||||||
WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com}
|
WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com}
|
||||||
|
# Reach the remote PostgreSQL endpoint through the default network's egress.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
@@ -61,7 +65,10 @@ services:
|
|||||||
<<: *observability-labels
|
<<: *observability-labels
|
||||||
observability.service: "web"
|
observability.service: "web"
|
||||||
logging: *container-logging
|
logging: *container-logging
|
||||||
image: ${IMAGE_PREFIX:?Set the Gitea registry/owner/image prefix}-web:${DEPLOY_TAG:?Set the published release tag}
|
image: wq-alpha-production-web:${DEPLOY_TAG:-local}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
environment:
|
environment:
|
||||||
SITE_ADDRESS: http://:80
|
SITE_ADDRESS: http://:80
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
+56
-102
@@ -1,143 +1,93 @@
|
|||||||
# Gitea 生产部署
|
# Gitea 生产部署
|
||||||
|
|
||||||
服务器分工:**A 提供 Gitea 和 Packages 镜像仓库,B 运行应用,C 运行 PostgreSQL**,B 与 C 通过内网连接。Gitea Runner 构建前后端镜像并推送到 A,然后通过 SSH 在 B 拉取镜像、检查配置与数据库、执行迁移并启动服务。Runner 可以在 A 或其他构建机上,无需访问 B 的 Docker socket。
|
本方案使用两台服务器:A 服务器托管 Gitea 和项目仓库;B 服务器运行 `tencent-prod-runner`,拉取 A 上的 `prod` 分支代码,在 B 上构建镜像并启动 Docker Compose。工作流通过标签 `tencent-prod` 选择该运行器。
|
||||||
|
|
||||||
生产使用独立的 `compose.production.yaml`,只引用镜像,不在 B 构建。B 上的 backend 和 migrate 通过 `DATABASE_URL` 连接 C,使用 Compose 默认网络,不依赖外部数据库 Docker 网络。Web 绑定 `127.0.0.1:8112`,由 B 的宿主机反向代理提供 HTTPS。`compose.yaml`、`compose.public.yaml` 的本地构建方式不变,不与生产文件叠加。
|
PostgreSQL 位于独立数据库服务器或云数据库,B 上的应用通过 `DATABASE_URL` 访问它,不依赖同机数据库容器或外部 Docker 网络。应用入口绑定 **B 服务器**的 `127.0.0.1:8112`,由 B 的宿主机反向代理提供公网 HTTPS。现有 `compose.yaml`、`compose.public.yaml` 保持独立,不与生产文件叠加。
|
||||||
|
|
||||||
## 1. 配置 Gitea Secrets 和 Variables
|
## 1. 填写配置和数据库账号密码
|
||||||
|
|
||||||
进入 **仓库 → 设置 → Actions → Secrets / Variables**。现有应用 Secrets 继续使用,新增镜像仓库和 SSH 配置。
|
进入 **Gitea 仓库 → 设置 → Actions → Secrets / Variables**,按下表创建同名配置。敏感值填写在 Secrets,工作流仅在部署步骤通过环境变量注入,不生成服务器凭据文件。
|
||||||
|
|
||||||
### Secrets
|
| 位置 | 名称 | 填写内容 |
|
||||||
|
|
||||||
| 名称 | 必填 | 填写内容 |
|
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `PROD_HOST` | 是 | B 的 SSH 主机名或 IP,不带协议、用户名和端口 |
|
| Secrets(必填) | `DATABASE_URL` | `postgresql+asyncpg://数据库账号:数据库密码@远程数据库域名或IP:端口/数据库名`,地址须能从 B 的应用容器访问 |
|
||||||
| `PROD_USER` | 是 | B 的 SSH 登录用户,能直接执行 Docker 命令,无需交互式 sudo |
|
| Secrets(必填) | `ADMIN_PASSWORD` | 系统初始管理员密码,至少 12 字符,与数据库密码独立 |
|
||||||
| `PROD_SSH_KEY` | 是 | 专用 SSH 私钥完整内容,保留换行;对应公钥放入 B 用户的 `~/.ssh/authorized_keys`;使用无需交互输入口令的部署密钥 |
|
| Secrets(必填) | `ENCRYPTION_KEY` | 下面命令生成的 Fernet 密钥,升级保持不变 |
|
||||||
| `PROD_KNOWN_HOSTS` | 是 | 经核验的 B 主机公钥,采用 OpenSSH `known_hosts` 格式,见下文 |
|
| Secrets(必填) | `WQ_EMAIL` | WorldQuant 登录邮箱 |
|
||||||
| `PROD_PORT` | 否 | B 的 SSH 端口,默认 `22` |
|
| Secrets(必填) | `WQ_PASSWORD` | WorldQuant 登录密码,按原样填写,不加引号 |
|
||||||
| `REGISTRY_USERNAME` | 是 | 有权访问镜像 owner 的 Gitea 用户名 |
|
| Variables(必填) | `PUBLIC_ORIGIN` | 实际 HTTPS 来源,例如 `https://alpha.your-domain.com`,不带路径或末尾 `/` |
|
||||||
| `REGISTRY_PASSWORD` | 是 | 该用户的 Gitea Personal Access Token,至少具备 `write:package` 权限(包含拉取权限),并有目标 owner 的包访问权限 |
|
| Variables(可选) | `ADMIN_USERNAME` | 初始管理员账号,默认 `admin` |
|
||||||
| `DATABASE_URL` | 是 | `postgresql+asyncpg://数据库账号:数据库密码@C的内网IP或域名:数据库端口/数据库名`,须从 B 的容器内可连接 |
|
| Variables(可选) | `MCP_ENABLED` | 设置 `true` 启用 MCP;未配置时默认关闭 |
|
||||||
| `ADMIN_PASSWORD` | 是 | 初始管理员密码,至少 12 字符,与数据库密码独立 |
|
|
||||||
| `ENCRYPTION_KEY` | 是 | Fernet 密钥,升级必须保持不变 |
|
|
||||||
| `WQ_EMAIL` | 是 | WorldQuant 登录邮箱 |
|
|
||||||
| `WQ_PASSWORD` | 是 | WorldQuant 登录密码,按原样填写,不加引号 |
|
|
||||||
|
|
||||||
### Variables
|
端口与 AI 限制使用 `compose.production.yaml` 的默认值,不需要在 Gitea 配置:`WEB_PORT=8112`、`AI_REQUEST_LIMIT=12`、`AI_TOOL_LIMIT=12`、`AI_OUTPUT_TOKENS=4096`、`AI_TIMEOUT=180`。需要调整时修改 Compose 中对应默认值;工作流不再读取这些同名 Gitea Variables。原有 `DATABASE_NETWORK` 不再使用,无需在 B 上创建同名外部网络。
|
||||||
|
|
||||||
| 名称 | 必填 | 填写内容 |
|
已配置的 `PROD_SSH_KEY`、`PROD_HOST`、`PROD_PORT`、`PROD_USER`、`PROD_KNOWN_HOSTS` 可以继续保留供远程运维使用。本流程由 B 的 Runner 直接部署,不读取这些 SSH Secrets;镜像在 B 本机构建和使用,也不读取 `REGISTRY_USERNAME`、`REGISTRY_PASSWORD`。
|
||||||
| --- | --- | --- |
|
|
||||||
| `IMAGE_PREFIX` | 是 | `A的仓库地址/owner/镜像名前缀`,全小写,不带 `https://` 或标签;当前仓库可填 `tea.bcc-life.cn/sakibcc/worldquant-alpha-system` |
|
|
||||||
| `DEPLOY_PATH` | 是 | B 上的绝对部署目录,例如 `/opt/wq-alpha`;SSH 用户须可写 |
|
|
||||||
| `BUILD_PLATFORM` | 否 | B 的 CPU 平台,默认 `linux/amd64`;ARM64 服务器填 `linux/arm64` |
|
|
||||||
| `PUBLIC_ORIGIN` | 是 | 实际 HTTPS 来源,例如 `https://alpha.your-domain.com`,不带路径或末尾 `/` |
|
|
||||||
| `ADMIN_USERNAME` | 否 | 初始管理员账号,默认 `admin` |
|
|
||||||
| `MCP_ENABLED` | 否 | `true` 启用 MCP,默认关闭 |
|
|
||||||
|
|
||||||
镜像命名遵循 Gitea 的 `registry/owner/image:tag` 规则。以上示例产生两个包:
|
数据库连接串示例(实际填写时替换示例值):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
tea.bcc-life.cn/sakibcc/worldquant-alpha-system-backend:<完整提交 SHA>
|
postgresql+asyncpg://wq_user:YOUR_PASSWORD@db.example.com:5432/wq_alpha
|
||||||
tea.bcc-life.cn/sakibcc/worldquant-alpha-system-web:<完整提交 SHA>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`REGISTRY_USERNAME` 是登录用户,`IMAGE_PREFIX` 中的 owner 是包所有者,两者在组织仓库中可能不同。Packages 属于该用户或组织,可在 Gitea 中关联到当前仓库。默认使用同一个 Token 推送和远程拉取,B 的部署进程因此也会接收该 Token。
|
生成加密密钥:
|
||||||
|
|
||||||
主机公钥应从可信的 B 控制台取得,并与 SSH 扫描结果核对。例如在可信管理终端运行 `ssh-keyscan -p 22 B_HOST`,通过 B 的控制台核对其指纹后,将核验过的完整行保存为 `PROD_KNOWN_HOSTS`。非默认端口的主机字段应为 `[B_HOST]:PORT`;主机名必须与 `PROD_HOST` 一致。工作流严格校验主机身份,不会在每次部署时自动信任扫描结果。
|
|
||||||
|
|
||||||
生成新安装的加密密钥:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -c 'import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())'
|
python3 -c 'import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())'
|
||||||
```
|
```
|
||||||
|
|
||||||
`DATABASE_URL` 中账号和密码的特殊字符须 URL 编码,例如 `@` → `%40`、`#` → `%23`、`/` → `%2F`。在 Gitea 输入框填写值本身,不加包裹引号或 `KEY=` 前缀;其他密码中的 `$`、引号和换行原样填写,SSH 传递会进行 Bash 转义。不要开启 `set -x` 或打印凭据。
|
`DATABASE_URL` 的用户名和密码中的特殊字符须做 URL 百分号编码,例如 `@` → `%40`、`#` → `%23`、`/` → `%2F`、`%` → `%25`。在 Gitea 输入框填写值本身,不添加包裹引号,不填写 `DATABASE_URL=` 前缀。管理员密码中的 `$`、引号等按原样填写,由环境变量传递,无需 shell 转义。不要在日志打印变量,或开启 `set -x`。
|
||||||
|
|
||||||
端口与 AI 限制继续使用 Compose 默认值:`WEB_PORT=8112`、`AI_REQUEST_LIMIT=12`、`AI_TOOL_LIMIT=12`、`AI_OUTPUT_TOKENS=4096`、`AI_TIMEOUT=180`。需要调整时修改 Compose 默认值;工作流不读取这些同名 Gitea Variables。
|
在远程 PostgreSQL 管理界面先创建专用数据库与账号,账号须可连接并拥有该库中应用 schema 的建表和迁移权限。数据库安全组、白名单或防火墙须允许 B 的实际出口地址访问数据库端口,且 B 的应用容器须能解析并访问数据库地址;使用私网地址时先确保 B 与数据库的私网路由连通。不要在连接串中沿用只在 A 上可解析的数据库容器名,也不要使用 `localhost` 或 `127.0.0.1` 指向远程数据库。
|
||||||
|
|
||||||
`DATABASE_URL` 示例(假设 C 的内网 IP 为 `10.0.0.30`,按实际账号、密码、端口和库名替换):
|
应用使用异步驱动 `asyncpg`;强制 TLS 或自定义 CA 的实例需按该实例要求配置兼容的连接参数及证书,不应直接假定其控制台提供的其他驱动连接串可用。部署脚本会在停止旧服务前,从 B 的后端容器进行真实数据库连接预检。
|
||||||
|
|
||||||
```text
|
如果从已有安装迁移数据,需恢复完整数据库并沿用原 `ENCRYPTION_KEY`;修改管理员环境变量不会重置已有管理员密码。
|
||||||
postgresql+asyncpg://wq_user:YOUR_PASSWORD@10.0.0.30:5432/wq_alpha
|
|
||||||
```
|
|
||||||
|
|
||||||
本版本不再读取或校验 `DATABASE_NETWORK`,Gitea 中的旧变量可以删除。不要用 C 上的容器名或 Docker 网络名作为跨主机连接地址。
|
WorldQuant 凭据由环境变量管理,启动时加密写入数据库;页面只保留连接操作。修改 `WQ_PASSWORD` 后重新运行部署即可更新。`WQ_EMAIL` 必须与已有绑定账户一致,避免混入其他账户数据;生产不读取 `account.json` 或本地 `.env`。
|
||||||
|
|
||||||
已有安装需沿用数据库和 `ENCRYPTION_KEY`;修改管理员环境变量不会重置已有密码。WorldQuant 凭据启动时加密写入数据库,`WQ_EMAIL` 必须与已有绑定账户一致;修改 `WQ_PASSWORD` 后重新部署即可更新。生产不读取 `account.json` 或 `.env`。
|
## 2. 配置 Gitea Runner
|
||||||
|
|
||||||
## 2. 准备 A、Runner、B 和 C
|
使用 B 服务器上已注册的 **`tencent-prod-runner`**,工作流的 `runs-on` 填写其标签 **`tencent-prod`**。只有 B 上用于生产部署的运行器应持有此标签。
|
||||||
|
|
||||||
### A:Gitea Packages
|
运行器的执行环境须通过 Docker CLI/Compose 操作 **B 宿主机的 Docker daemon**。可以沿用现有 host 或 Docker 执行模式:host 模式需要执行用户有权访问 B 的 Docker;容器模式需要部署步骤所在的作业容器能够访问 B 的 Docker socket(通常是 `/var/run/docker.sock`),且具有 Docker CLI/Compose。只给 Runner 管理容器挂载 socket 并不能证明作业容器内也可访问;不要将部署命令连接到 A 的远程 Docker 或临时 Docker-in-Docker 实例。
|
||||||
|
|
||||||
启用仓库 Actions 和 Gitea Packages。Runner 与 B 都须能解析并访问 `IMAGE_PREFIX` 中的仓库地址;HTTPS 证书须被两端 Docker 信任。Gitea 前的反向代理须正确转发 `/v2/` 并允许镜像层上传。本流程不自动修改 Docker 的 insecure registry 或 TLS 设置。
|
Runner 注册的 Gitea 地址及仓库克隆地址必须能从 B 和作业执行环境访问,应使用 A 的实际域名或可达 IP;不能使用指向 A 本机的 `localhost`、`127.0.0.1` 或仅在 A 的 Docker 网络内可解析的容器名。
|
||||||
|
|
||||||
### Runner:构建与 SSH
|
执行环境需要 Git、Bash、Node.js 20(checkout v4)以及支持 `up --wait --wait-timeout` 的 Docker Compose。本流程不需要 `flock` 或预建 `/opt/wq-alpha`。服务器需能访问 checkout action、基础镜像仓库和依赖源。
|
||||||
|
|
||||||
继续使用 `ubuntu-latest` 标签,该标签对应的**实际执行环境**需要 Git、Bash、Node.js 20(checkout v4)、Docker CLI/Buildx、OpenSSH 客户端、tar、base64,并能连接构建机的 Docker daemon。容器模式 Runner 也需要具备这些工具和 Docker 连接能力。
|
在仓库启用 Actions,向 **`prod`** push 会部署;手动运行也须选择 **`prod`**,选择其他分支时本版工作流会跳过部署任务。Checkout 使用本次触发的提交,构建和迁移均在 B 的执行环境完成,无需 A 通过 SSH 再复制代码到 B。
|
||||||
|
|
||||||
Runner 需要访问 checkout/QEMU action、基础镜像仓库、依赖源、A 的镜像仓库和 B 的 SSH 端口。`BUILD_PLATFORM` 必须匹配 B:当前 Runner 为 `aarch64`、B 为 `x86_64`,仍应配置 `linux/amd64`(或使用默认值)。
|
本次修改仅在 `prod` 分支。`main` 中的旧工作流仍配置为 `main` push → `ubuntu-latest`,不会随 `prod` 的修改自动停用;如需停用旧部署,须另行修改 `main` 的工作流。
|
||||||
|
|
||||||
工作流先通过 `docker/setup-qemu-action` 注册 amd64/arm64 模拟支持,再创建独立的 `docker-container` Buildx 构建器。QEMU 初始化需要构建机的 Docker daemon 允许运行特权容器并注册 binfmt;此操作作用于构建机。日志会显示构建机架构、目标平台和构建器支持的平台。Gitea 中禁用该 action 的 GitHub 缓存功能。前后端均显式使用同一个构建器,成功或失败后清理构建器及临时认证目录,不切换 Runner 原有默认构建器。跨架构模拟通常比同架构构建慢。
|
Secrets 仍在 A 的 Gitea 仓库设置中管理,通过部署步骤的环境变量注入 B 上的作业,`--env-file /dev/null` 防止误读开发 `.env`。构建可并行;构建完成后,以 B 的 Docker 唯一容器名 `wq-alpha-production-deploy-lock` 互斥保护预检、迁移和服务切换。锁容器不启动、不携带凭据,正常结束或失败时删除;竞争失败的任务不会删除其他任务的锁。
|
||||||
|
|
||||||
### B:运行环境
|
如果 Runner 被强制终止或 Docker 断连,可能留下锁容器。先确认没有本项目部署正在执行,再手动运行 `docker rm wq-alpha-production-deploy-lock` 后重试。不要在部署进行时删除锁。
|
||||||
|
|
||||||
安装 Bash、tar、base64、Docker Engine 和支持 `pull --policy`、`run --pull`、`up --wait --wait-timeout` 的 Docker Compose 插件。SSH 用户需要直接操作 B 的本机 Docker daemon;不需要 Git、Node.js、Python 或源码 checkout。脚本使用临时 `DOCKER_CONFIG`,不要依赖用户配置目录中的命名 Docker context;使用本机默认 socket。
|
## 3. 配置反向代理并首次运行
|
||||||
|
|
||||||
在 B 创建部署目录并交给部署用户,例如将示例用户 `deploy` 换成实际的 `PROD_USER`:
|
将 `PUBLIC_ORIGIN` 对应域名解析到 **B 服务器**,在 B 配置 HTTPS 反向代理,转发至 **`http://127.0.0.1:8112`**(或你的 `WEB_PORT`)。此回环地址只对 B 本机有效;A 上的代理不能通过它访问 B。代理须运行在 B 的宿主机网络中;独立 bridge 容器中的 `127.0.0.1` 不指向宿主机。若使用容器化 1Panel/OpenResty,先确认其网络模式。AI 流式响应需要关闭代理缓冲,并允许长连接/足够长的读取超时。
|
||||||
|
|
||||||
```bash
|
首次部署建议在 Gitea Actions 页面选择 **`prod`** 分支手动运行工作流,确认任务由 `tencent-prod-runner` 接收。需要在服务器排障运行时,在 **B 服务器的 `prod` 检出目录**中先从安全渠道将上表必填配置注入当前进程环境,再执行:
|
||||||
sudo install -d -m 700 -o deploy -g deploy /opt/wq-alpha
|
|
||||||
```
|
|
||||||
|
|
||||||
将部署公钥加入该用户的 `authorized_keys`,核对 B 的反向代理及应用容器到 C 数据库端口的连通性。B、C 主机内网互通不能替代容器内的连接验证。
|
|
||||||
|
|
||||||
### C:数据库
|
|
||||||
|
|
||||||
在 C 创建专用数据库和账号,赋予该库应用 schema 的建表与迁移权限。PostgreSQL 须监听内网可访问的接口,防火墙和 `pg_hba.conf` 须允许 B 的连接来源。默认 Docker bridge 出站通常使用 B 的主机地址,若配置了额外路由或 NAT,以 C 实际收到的来源地址为准。
|
|
||||||
|
|
||||||
若 PostgreSQL 运行在 C 的 Docker 容器中,须发布到 C 的内网地址,例如 `10.0.0.30:5432:5432`;`DATABASE_URL` 使用 C 的内网 IP 和发布端口。强制 TLS 的实例还需按数据库证书及连接要求配置客户端参数。部署流程只在 B 执行迁移,通过数据库连接访问 C,无需 C 的 SSH 凭据。
|
|
||||||
|
|
||||||
## 3. 部署流程与首次验收
|
|
||||||
|
|
||||||
`main` push 或手动运行工作流会依次执行:
|
|
||||||
|
|
||||||
1. checkout 当前提交,初始化 QEMU 和独立 Buildx 构建器,按 B 的目标平台构建 backend/web,分别推送到 A 的 Gitea Packages。两个镜像均推送成功才执行 SSH 部署。
|
|
||||||
2. `scripts/deploy-remote.sh` 严格验证 B 主机公钥,通过同一 SSH 连接传入应用环境变量、生产 Compose 和部署脚本。
|
|
||||||
3. B 为每次尝试创建 `DEPLOY_PATH/releases/<提交 SHA>.<随机后缀>/`,只保存 `compose.production.yaml` 和 `scripts/deploy-production.sh`,无需更新项目源码。
|
|
||||||
4. B 登录 A 的仓库并拉取两个镜像。随后获得 Docker 部署锁,运行新 backend 镜像校验配置和数据库连接。
|
|
||||||
5. 记录旧镜像 ID/标签,停止 web/backend,执行一次性迁移,再使用已拉取的镜像启动并等待健康检查。后端保持一个实例、一个 worker。
|
|
||||||
|
|
||||||
Compose 项目名固定为 `wq-alpha-production`,所以独立发布目录不会创建第二套应用或卷。迁移使用同一 backend 镜像;停止服务后不再访问仓库或构建。迁移或健康检查失败会让 SSH 和 Actions 返回失败。
|
|
||||||
|
|
||||||
应用凭据通过 SSH 标准输入传递,不写入发布目录或 `.env`;Compose 显式使用 `--env-file /dev/null`。SSH 私钥与 Docker 登录信息使用权限受限的临时目录,正常结束或失败时清理,不覆盖用户原有 Docker 登录配置。进程被强制杀死时可能残留临时文件;拥有 Runner、B 或 Docker 管理权限的人仍可读取进程和容器运行时配置。
|
|
||||||
|
|
||||||
Docker 唯一容器名 `wq-alpha-production-deploy-lock` 保护预检、迁移及服务切换;锁容器不运行、不携带凭据。并发部署竞争失败会退出,且只清理自己取得的锁。Runner 被强制终止或 Docker 断连时可能留下锁;确认没有部署正在执行后,可在 B 手动删除:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker rm wq-alpha-production-deploy-lock
|
|
||||||
```
|
|
||||||
|
|
||||||
B 的宿主机 HTTPS 反向代理应转发到 `http://127.0.0.1:8112`。独立 bridge 容器中的 `127.0.0.1` 不指向宿主机;使用 1Panel/OpenResty 时需确认其网络模式。AI 流式响应需要关闭代理缓冲,并设置足够的读取超时。
|
|
||||||
|
|
||||||
首次部署建议手动运行工作流,确认 Packages 中有两个提交标签、B 上容器健康,再通过公网检查 `/api/v1/health`、登录、写请求和 Cookie。容器健康检查不替代 DNS、HTTPS 和真实 Runner 验收。
|
|
||||||
|
|
||||||
## 4. 升级、备份和失败处理
|
|
||||||
|
|
||||||
每次升级包含停机窗口;提前暂停或结束长时间任务。发布前通过数据库管理工具备份专用库,并在独立安全位置备份加密密钥及必要配置。此工作流不会自动备份或自动恢复数据库。
|
|
||||||
|
|
||||||
构建、推送、建立 SSH 连接、拉取或预检阶段失败时不会停止旧服务。停止服务后的迁移、健康检查失败或 SSH 中断需要在 B 核对实际状态并人工处理;不要对可能已变更的 schema 自动降级。Actions 日志记录切换前的镜像 ID/标签,成功后输出当前提交。两端均不执行 image prune;保留需要回退的 Packages 标签及 B 上的发布目录。
|
|
||||||
|
|
||||||
提交标签用于定位版本;同一提交重跑会重新构建并覆盖同名标签,严格核对历史产物时使用日志中的镜像 ID/digest。回退须先确认旧代码与当前 schema 兼容,然后从安全渠道注入对应配置、`IMAGE_PREFIX`、旧 `DEPLOY_TAG` 和仓库凭据,在匹配旧版本的发布目录运行:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/deploy-production.sh
|
bash scripts/deploy-production.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
新流程产生的发布目录只含部署文件,手动执行仍需注入完整环境。若旧 schema 不兼容,先停止应用、人工确认后从经过验证的备份恢复数据库,再使用原加密密钥和对应旧版本启动;恢复会丢失备份后的写入。不要仅为回退重跑旧的“同机构建”工作流。
|
固定 Compose 项目名为 `wq-alpha-production`,后端保持一个实例、一个 worker。脚本先验证 Compose,构建按 Git 提交标记的镜像,再检查密钥格式和数据库连通性;随后停止 web/backend、执行一次性迁移、启动服务并等待健康检查。Web 健康检查同时覆盖页面和经 Caddy 转发的数据库健康接口。迁移或启动失败时非零退出并显示容器状态,不继续标记成功。
|
||||||
|
|
||||||
以下命令在 B 执行,不依赖凭据环境变量:
|
从旧服务器切换时,若旧实例仍连接同一生产数据库,须在 B 上执行迁移前停止旧实例及其定时任务。脚本和部署锁只管理 B 上的容器,不会停止 A 上的旧应用,也不能协调两台服务器对同一数据库的迁移和写入。
|
||||||
|
|
||||||
|
首次部署完成后,检查公网 `/api/v1/health`,再通过真实域名登录并确认页面、写请求和 Cookie 正常。容器健康通过不能替代 HTTPS、DNS 和真实 Gitea Runner 验收。
|
||||||
|
|
||||||
|
## 4. 升级、备份和失败处理
|
||||||
|
|
||||||
|
升级包含停机窗口;提前结束或暂停长时间任务。每次发布前通过现有数据库管理工具备份专用库,并在独立安全位置备份加密密钥及必要配置,先在独立库验证恢复。此工作流不会自动备份或自动恢复数据库。
|
||||||
|
|
||||||
|
构建及预检失败时旧服务继续运行。停止服务后的迁移或健康检查失败需要人工处理;不要对可能已变更的 schema 直接自动降级。脚本在 Actions 日志输出切换前的镜像 ID/标签,成功后输出当前提交标识;保留旧镜像且不执行 prune。请保留部署日志作为回退参考,不再依赖宿主机版本记录文件。
|
||||||
|
|
||||||
|
如旧代码与当前 schema 兼容,可检出旧提交并指定其镜像标签启动;否则先停止应用,使用经过验证的备份恢复数据库,再用原加密密钥和对应旧版本启动。数据库恢复会丢失备份后的写入,必须人工确认后执行。本配置没有自动数据库降级,也不承诺无停机升级。
|
||||||
|
|
||||||
|
以下排查命令在 **B 服务器**运行,不依赖凭据环境变量(可能含业务信息的日志请勿公开):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker ps -a --filter label=com.docker.compose.project=wq-alpha-production
|
docker ps -a --filter label=com.docker.compose.project=wq-alpha-production
|
||||||
@@ -145,13 +95,17 @@ docker logs --tail=100 wq-alpha-production-backend-1
|
|||||||
docker logs --tail=100 wq-alpha-production-web-1
|
docker logs --tail=100 wq-alpha-production-web-1
|
||||||
```
|
```
|
||||||
|
|
||||||
不要公开含业务信息的日志,普通维护不要使用 `down -v`。发布目录可在确认无需回退后人工清理,不影响 named volumes;数据库备份和恢复在数据库管理端进行。
|
普通维护不要使用 `down -v`。这里的数据库由外部管理,备份与恢复应在数据库管理端进行。
|
||||||
|
|
||||||
配置依据:[Gitea Container Registry](https://docs.gitea.com/usage/packages/container/)、[Docker 跨平台构建](https://docs.docker.com/build/building/multi-platform/)、[QEMU action](https://github.com/docker/setup-qemu-action/tree/v3)、[Docker 登录](https://docs.docker.com/reference/cli/docker/login/)、[Compose pull](https://docs.docker.com/reference/cli/docker/compose/pull/)、[Compose run](https://docs.docker.com/reference/cli/docker/compose/run/)、[Compose up](https://docs.docker.com/reference/cli/docker/compose/up/)。
|
## 配置依据
|
||||||
|
|
||||||
|
生产/开发配置隔离,使用必填凭据、固定项目名、独立迁移和部署健康检查。生产服务使用 Compose 默认网络互通,并通过该网络访问远程 PostgreSQL;无需预建数据库 Docker 网络。本应用后端不写 named volume,已有镜像使用 UID 10001。
|
||||||
|
|
||||||
|
凭据存放在 Gitea Secrets,通过步骤级环境变量交给 Compose,不落地到配置文件。Docker 容器仍需持有运行时配置,因此拥有 Runner 或 Docker 管理权限的人仍可能读取它们。若此前手动创建了旧 `.env.production`,新流程不再读取它;确认配置已迁移到 Gitea 并妥善备份密钥后可自行移除旧文件。相关官方资料:[Compose 网络](https://docs.docker.com/compose/how-tos/networking/)、[环境变量插值](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/)、[Gitea Runner 2.x 标签](https://docs.gitea.com/runner/2/labels/)、[Gitea 分支上下文](https://docs.gitea.com/usage/actions/actions-variables/)。
|
||||||
|
|
||||||
## 5. 1Panel 夜间全量目录同步
|
## 5. 1Panel 夜间全量目录同步
|
||||||
|
|
||||||
在 1Panel 的计划任务中建立 Shell 脚本任务,执行周期由 1Panel 设置,例如每天深夜执行。每次命令明确指定一个范围:
|
在 **B 服务器**的 1Panel 计划任务中建立 Shell 脚本任务,执行周期由 1Panel 设置,例如每天深夜执行。每次命令明确指定一个范围:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec wq-alpha-production-backend-1 \
|
docker exec wq-alpha-production-backend-1 \
|
||||||
|
|||||||
@@ -51,3 +51,9 @@ dateCoverage 只是一项元数据,不告诉你缺失发生在哪几年,也
|
|||||||
[MAX 与彩票偏好帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43508107778711)(2026-09-15)报告滚动极端涨幅除以自身波动后,Sharpe 从 0.36 到 0.75,最大回撤从 48.96% 到 5.69%;缩短为 5 日窗口虽进一步提高指标,作者也承认与短期反转重叠。可保留“极值相对自身波动”的待验证假设,但窗口更短、更强不等于彩票机制更纯。本轮未复核论文、借券成本或原始回测,也不将标题中的“完整复现”当作已验证结论。
|
[MAX 与彩票偏好帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43508107778711)(2026-09-15)报告滚动极端涨幅除以自身波动后,Sharpe 从 0.36 到 0.75,最大回撤从 48.96% 到 5.69%;缩短为 5 日窗口虽进一步提高指标,作者也承认与短期反转重叠。可保留“极值相对自身波动”的待验证假设,但窗口更短、更强不等于彩票机制更纯。本轮未复核论文、借券成本或原始回测,也不将标题中的“完整复现”当作已验证结论。
|
||||||
|
|
||||||
[DEU 内部人信念强度帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43178926004887)(2026-09-02,本轮补录)提醒:稀疏事件填零后,大块相同排名可能代表没有事件,不能解释为经济信念中等。作者还报告有效持仓主要在 2018–2023,而较早年份为零;应以实际可投资区间评估结果。比较滚动和与均值时须核对有效观测数和缺失处理;完整固定窗口下两者只差常数,不能笼统断言换成均值必然丢失事件信息。原帖的 tail 阈值与性能改善仅是单例,不推广为稀疏数据通用模板。
|
[DEU 内部人信念强度帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43178926004887)(2026-09-02,本轮补录)提醒:稀疏事件填零后,大块相同排名可能代表没有事件,不能解释为经济信念中等。作者还报告有效持仓主要在 2018–2023,而较早年份为零;应以实际可投资区间评估结果。比较滚动和与均值时须核对有效观测数和缺失处理;完整固定窗口下两者只差常数,不能笼统断言换成均值必然丢失事件信息。原帖的 tail 阈值与性能改善仅是单例,不推广为稀疏数据通用模板。
|
||||||
|
|
||||||
|
## 2026-09-25 补充:非线性变换与覆盖需要分开检验
|
||||||
|
|
||||||
|
[EUR 财报电话会情绪概率差](https://support.worldquantbrain.com/hc/en-us/community/posts/43620895448343)(2026-09-20)报告 signed power 降相关,并描述早期稀疏截面造成权重集中。随附评论明确指出另一字段的成功不能证明“概率差 + signed_power”有效;应固定其余设置分别对照回填窗、覆盖和 truncation,拆开表达式变换与股票池变化。原帖指标属于作者自报,未复现。
|
||||||
|
|
||||||
|
[ASI 借券费率方差案例](https://support.worldquantbrain.com/hc/en-us/community/posts/43624312881943)(2026-09-20)比较方差与标准差版本。即使同一有效样本下平方保持非负值的截面排序,后续线性缩放、中性化或权重处理也可能得到不同持仓,不能由“同一排序”推出整条策略等价。作者由少数 checks 推断的 robust 比率约 0.9 仅是其样本观察,不写成通用平台门槛;每次仍需读取实际 value、limit 和状态。
|
||||||
|
|||||||
@@ -31,3 +31,11 @@ FastExpr 已能表达的简单机制,先用它建立基线。需要自定义
|
|||||||
来源:[[Python Alpha 挑战赛]Python Alpha两阶段筛选法:FastExpr快速验证 + Python深度优化](https://support.worldquantbrain.com/hc/en-us/community/posts/41135490599063)(发帖 2026-06-11,社区经验);[从 Fast 翻译到 Python Alpha 别急着炫技:截面/时序错位是头号杀手,事件漂移和中性化层级是隐形雷](https://support.worldquantbrain.com/hc/en-us/community/posts/42047259261335)(发帖 2026-07-18,社区经验);[【经验分享】Python Alpha 30名心得:从 FastExpr 到 Python,我用 17 个 alpha 趟出来的一条路](https://support.worldquantbrain.com/hc/en-us/community/posts/42519002511767)(发帖 2026-08-06,社区经验);[Python Alpha 平台限制粗略小结,请大佬指正](https://support.worldquantbrain.com/hc/en-us/community/posts/40742727142679)(发帖 2026-05-26,社区经验)。
|
来源:[[Python Alpha 挑战赛]Python Alpha两阶段筛选法:FastExpr快速验证 + Python深度优化](https://support.worldquantbrain.com/hc/en-us/community/posts/41135490599063)(发帖 2026-06-11,社区经验);[从 Fast 翻译到 Python Alpha 别急着炫技:截面/时序错位是头号杀手,事件漂移和中性化层级是隐形雷](https://support.worldquantbrain.com/hc/en-us/community/posts/42047259261335)(发帖 2026-07-18,社区经验);[【经验分享】Python Alpha 30名心得:从 FastExpr 到 Python,我用 17 个 alpha 趟出来的一条路](https://support.worldquantbrain.com/hc/en-us/community/posts/42519002511767)(发帖 2026-08-06,社区经验);[Python Alpha 平台限制粗略小结,请大佬指正](https://support.worldquantbrain.com/hc/en-us/community/posts/40742727142679)(发帖 2026-05-26,社区经验)。
|
||||||
|
|
||||||
本次没有安装或运行 BRAIN Labs Python SDK,所以这里只提供官方入口与迁移验证设计,不提供宣称可直接运行的 SDK 脚本。
|
本次没有安装或运行 BRAIN Labs Python SDK,所以这里只提供官方入口与迁移验证设计,不提供宣称可直接运行的 SDK 脚本。
|
||||||
|
|
||||||
|
## 2026-09-25 补充:频域与 PCA 实现先验算
|
||||||
|
|
||||||
|
[FFT 复现纠错帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43640747372695)(2026-09-21)指出一个可直接验算的恒等式:对同一输入 x 的互补频带,slow + fast = x,因此 slow − x 与 −fast 是同一条腿,混合权重不会产生新信息。这只适用于所示互补分解及一致预处理,不能泛化为所有慢快信号必然共线。
|
||||||
|
|
||||||
|
原帖的其余“修正”仍需审查。对称 Hann 窗末端为零只能推出重构后的 slow[-1] + fast[-1] = 0,不能单凭这一点断言两个分量各自为零或必然衰减固定倍数。正文提倡前值填充,但修正版实际用窗口列均值填缺失;二者不是同一种经济假设。代码 `hist[-WIN:]` 在 WIN=65 时取 65 行,与注释宣称的 66 行也不一致。14–17 倍加速、泄漏比例和回测效果均为作者报告,本库未运行代码。
|
||||||
|
|
||||||
|
[PCA 特质残差反转帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43692787685015)(2026-09-23)可作为待验证的自估风险模型思路,但代码 `cs[H-1:] - cs[:-H+1]` 每项相减的累计索引间隔为 H−1,不能直接标成 H 日收益和;它还将非有限收益填零,需核对缺失、上市历史与股票池影响。作者报告 IND 有效而 EUR/USA/HKG 不佳,并将 PnL 相关 0.963 称作“恒等变换”;这是近似经验,非数学恒等。不得照搬其地区结论或部分正交化系数。
|
||||||
|
|||||||
@@ -49,3 +49,11 @@ CSV 本身不保证并发原子性。需要记录任务身份、租约或领取
|
|||||||
同帖还包含自动 PATCH Alpha 标签的步骤,因而不是纯读取工具。[另一篇 RA 说明](https://support.worldquantbrain.com/hc/en-us/community/posts/43410200668823)(2026-09-11)所述“不设置属性、不提交”不能自动套用到不同脚本。[AlphaSubmitter](https://support.worldquantbrain.com/hc/en-us/community/posts/43299585796247)(2026-09-07,本轮补录)宣传多轮自动重试,但没有给出足够的幂等或受理状态核对证据;POST 超时不能直接解释为服务端未创建,重发前需先对账。
|
同帖还包含自动 PATCH Alpha 标签的步骤,因而不是纯读取工具。[另一篇 RA 说明](https://support.worldquantbrain.com/hc/en-us/community/posts/43410200668823)(2026-09-11)所述“不设置属性、不提交”不能自动套用到不同脚本。[AlphaSubmitter](https://support.worldquantbrain.com/hc/en-us/community/posts/43299585796247)(2026-09-07,本轮补录)宣传多轮自动重试,但没有给出足够的幂等或受理状态核对证据;POST 超时不能直接解释为服务端未创建,重发前需先对账。
|
||||||
|
|
||||||
[意见留言墙](https://support.worldquantbrain.com/hc/en-us/community/posts/42302637812503)本轮新可见评论提出登录过期导致界面状态丢失、回测次数与 Alpha 列表数量不同等问题。它们是用户反馈,不是已确认平台故障:先保存任务引用与界面筛选状态,再分别核对请求次数、失败/取消状态和结果对象数,避免仅凭两个总数推断丢数据。本轮没有复现相关 UI 行为。
|
[意见留言墙](https://support.worldquantbrain.com/hc/en-us/community/posts/42302637812503)本轮新可见评论提出登录过期导致界面状态丢失、回测次数与 Alpha 列表数量不同等问题。它们是用户反馈,不是已确认平台故障:先保存任务引用与界面筛选状态,再分别核对请求次数、失败/取消状态和结果对象数,避免仅凭两个总数推断丢数据。本轮没有复现相关 UI 行为。
|
||||||
|
|
||||||
|
## 2026-09-25 补充:缺失状态不能被默认值掩盖
|
||||||
|
|
||||||
|
[回测用量帖的新增可见评论](https://support.worldquantbrain.com/hc/en-us/community/posts/43058251853847#community_comment_43553064668183)(2026-09-17,本轮补齐)指出,没有匹配到当天活动记录时直接置 used=0,可能把日期错位或接口异常解释成额度充足。适合作为审查项:缺记录保留 UNKNOWN,再核对日期与响应;评论所述日界、固定上限和具体 API 行为未由本轮核验。
|
||||||
|
|
||||||
|
[ProdMemo 桥接帖评论](https://support.worldquantbrain.com/hc/en-us/community/posts/42585946296855#community_comment_43606520549399)(2026-09-19)提出旧缓存覆盖新记录的风险。合并时应保留数据类型、采集时间与来源范围,并核对部分结果是否覆盖完整结果;这是评论提供的待核审查点,本轮没有重新读取并运行桥接脚本,不能直接宣称已确认其实现缺陷。
|
||||||
|
|
||||||
|
[多 Agent 研究流程](https://support.worldquantbrain.com/hc/en-us/community/posts/43623479962007)(2026-09-20)把字段证据、研究假设、实验结果与审计裁定分开,是可用的职责设计:字段存在不证明逻辑成立,回测返回结果不证明通过审计。它是作者的架构方案,不是多代理提高样本外表现的实证。
|
||||||
|
|||||||
@@ -18,3 +18,9 @@
|
|||||||
大规模搜索时特别检查:收益是否集中在某一年;低 PC 是否只来自缺失或不同日期;高 Sharpe 是否由不可投资的极端持仓产生;同一模板换字段是否仍是同一风险来源;回填是否把旧事件变成持续仓位。
|
大规模搜索时特别检查:收益是否集中在某一年;低 PC 是否只来自缺失或不同日期;高 Sharpe 是否由不可投资的极端持仓产生;同一模板换字段是否仍是同一风险来源;回填是否把旧事件变成持续仓位。
|
||||||
|
|
||||||
本知识库中的研究方向没有经过上述实际回测终验,均是待研究候选。
|
本知识库中的研究方向没有经过上述实际回测终验,均是待研究候选。
|
||||||
|
|
||||||
|
## 2026-09-25 补充:冻结发生在看结果之前
|
||||||
|
|
||||||
|
[时间前推与冻结流程](https://support.worldquantbrain.com/hc/en-us/community/posts/43638422558103)(2026-09-21,社区方法建议)区分历史分年诊断、时间前推与前瞻观察。看过完整历史后再按年拆分,不能恢复被选择过程用掉的独立性。每个验证段开始前记录表达式、完整 settings、冻结时间和淘汰条件;改动生成新版本,保留失败段,汇总冻结后各段的日收益,而非只展示最好一段或平均各段 Sharpe。
|
||||||
|
|
||||||
|
若标签或持有期跨越分割点,隔离范围应依据实际重叠,而非机械等于最长回看窗口。冻结卡和影子观察是研究建议,不是平台提交规则,也不能据此把个人留出段称为平台隐藏 OS。本轮未复核文中外部论文或运行验证实验。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 来源、覆盖范围与验证记录
|
# 来源、覆盖范围与验证记录
|
||||||
|
|
||||||
首次采集日期:2026-09-07;最近论坛同步:2026-09-18。下列初轮与扩展轮数字保留为历史记录,最新覆盖见文末周同步记录。时间边界按 Asia/Shanghai 的 2026-01-01 00:00 至整理时点;原始索引时间使用 UTC/带偏移的 ISO 8601。日历日简写通常取原来源 UTC 日期,未把它伪装成本地精确时刻。
|
首次采集日期:2026-09-07;最近论坛同步:2026-09-25(有访问缺口)。下列初轮与扩展轮数字保留为历史记录,最新覆盖见文末周同步记录。时间边界按 Asia/Shanghai 的 2026-01-01 00:00 至整理时点;原始索引时间使用 UTC/带偏移的 ISO 8601。日历日简写通常取原来源 UTC 日期,未把它伪装成本地精确时刻。
|
||||||
|
|
||||||
## 邮箱
|
## 邮箱
|
||||||
|
|
||||||
@@ -108,3 +108,17 @@ SPC 7 篇官方帮助页按 ID 直接读取;另通过支持站搜索取得 Pyt
|
|||||||
本轮继续比较全部范围内评论的文本指纹,没有假定帖子更新时间或评论数量能完全反映评论编辑。评论指纹针对去除 HTML、规范化空白后的文字,不覆盖纯排版、链接目标或图片替换;因此“未见评论文本编辑”不能解释为所有媒体与格式均未变化。上周文本保留在本机 `.local/forum-sync/previous-2026-09-11/`,新候选仍由验证后才推进到相应成功基线,图片访问缺口另行保留;状态与采集文本不包含认证响应或会话材料。没有同步邮件、改变平台属性、预约、报名、回测、发帖或推送仓库。
|
本轮继续比较全部范围内评论的文本指纹,没有假定帖子更新时间或评论数量能完全反映评论编辑。评论指纹针对去除 HTML、规范化空白后的文字,不覆盖纯排版、链接目标或图片替换;因此“未见评论文本编辑”不能解释为所有媒体与格式均未变化。上周文本保留在本机 `.local/forum-sync/previous-2026-09-11/`,新候选仍由验证后才推进到相应成功基线,图片访问缺口另行保留;状态与采集文本不包含认证响应或会话材料。没有同步邮件、改变平台属性、预约、报名、回测、发帖或推送仓库。
|
||||||
|
|
||||||
验证结果:两个 CSV 各有 670 个唯一帖子 ID,评论合计 9,601;采集记录及候选指纹逐项一致,142 篇帖子被知识条目引用,论坛引用均在范围内,本地链接与差异空白检查通过。新增知识内容和本地 JSON/JSONL 状态的账号密码字面值及 JWT 形式扫描通过。实际 API 采集及本周内容比对已运行;社区回测与代码未执行,自动失败恢复、限流退避仍未经过故障演练。文本及索引同步完成,图片正文访问失败保留为独立缺口。
|
验证结果:两个 CSV 各有 670 个唯一帖子 ID,评论合计 9,601;采集记录及候选指纹逐项一致,142 篇帖子被知识条目引用,论坛引用均在范围内,本地链接与差异空白检查通过。新增知识内容和本地 JSON/JSONL 状态的账号密码字面值及 JWT 形式扫描通过。实际 API 采集及本周内容比对已运行;社区回测与代码未执行,自动失败恢复、限流退避仍未经过故障演练。文本及索引同步完成,图片正文访问失败保留为独立缺口。
|
||||||
|
|
||||||
|
## 每周论坛同步:2026-09-25
|
||||||
|
|
||||||
|
实际采集时间为北京时间 09:12:33–09:16:29,随后完成筛读和知识整理。认证返回 201,支持站 SSO 落地 403,Community API 正常。完整取得 19 页、1,859 篇可见帖子,排除 1,101 篇早于年初边界的帖子;本次范围内实际取得 758 篇、10,141 条评论,逐帖分页计数一致,无评论抓取失败。
|
||||||
|
|
||||||
|
相对 9 月 18 日内容基线,新收录 89 篇,其中 72 篇发表于上次采集结束后,17 篇为历史补录。新收录帖带来 9 条评论,217 篇旧帖另有 540 个新可见评论 ID;这 549 条中,67 条发表于上次采集后、482 条创建时间更早,是本轮历史覆盖补齐。旧帖元数据更新时间变化 329 篇,但既有正文指纹无变化,可见帖既有评论没有文本编辑或缺失 ID;没有仅凭更新时间或评论数判断未变。
|
||||||
|
|
||||||
|
此前收录的 41208709211671(傅里叶变换在 Alpha 开发中的应用,2026-06-14)本次列表缺失,直接官方 API 请求也返回 404,原因未定。保留原文快照、引用、索引及其历史 9 条评论,不把 404 解释为已删除。因而当前索引有 759 个 ID:758 个本次可见,1 个历史保留;阅读索引评论数合计 10,150,其中 9 条未在本次重新取得,该帖 comments_complete 记为 False。本次实际取得评论比上次增加 540,等于新可见 549 减去未能重新取得的历史 9,不能将净增量称作新发表评论。
|
||||||
|
|
||||||
|
89 篇新收录帖完成标题及正文主旨筛查,专题目录只作导航,未沿目录扩充 2026 年前帖子;福利、收入展示和未答问题保留索引,不扩写为结论。新收录帖的 9 条评论及旧帖新可见的 540 条评论完成筛读,并针对实质纠错和机制建议精读。新增知识集中在冻结后验证、FFT 互补频带恒等式及修正版局限、PCA 窗口索引、聚类代表不能替代逐项 PC 终验、缺失值语义与 UNKNOWN 状态。社区数值仍为作者自述;本次没有复现回测、安装或执行社区代码,也没有将评论中的配额、评分、时区和阈值升级为官方规则。
|
||||||
|
|
||||||
|
图片帖 43287990820375 仍可取得元数据,正文含 12 张图片;本次抽查其中 1 个官方图片地址仍为 403,没有把抽查写成全部重试,继续保留 metadata_only 和待阅读项。评论指纹仍仅覆盖规范化文字,不检测纯格式、链接目标或图片替换。本次保留上周 670 篇文本快照于本机 previous-2026-09-18/;可见记录验证后推进相应基线,不可访问帖保留旧指纹与重试缺口。此次为可见文本及索引完成、来源与媒体仍有缺口的部分同步,不宣称全量无缺失。
|
||||||
|
|
||||||
|
验证结果:两个 CSV 均有 759 个唯一 ID;758 个本次采集记录与候选指纹、逐评论文本哈希及分页计数一致,另 1 个历史保留记录明确标为当前未完成。154 篇帖子被知识条目引用,引用均属于本次可见范围或明确保留的历史来源。本地链接、差异空白、新增内容及本地 JSON/JSONL 的账号密码字面值和 JWT 形式扫描通过。采集脚本语法检查通过;本轮只验证实际采集与静态内容比对,没有故障注入、策略回测或社区代码执行。
|
||||||
|
|||||||
@@ -29,75 +29,295 @@
|
|||||||
"No source authentication material is included."
|
"No source authentication material is included."
|
||||||
],
|
],
|
||||||
"latest_forum_sync": {
|
"latest_forum_sync": {
|
||||||
"collected_at": "2026-09-18",
|
"collected_at": "2026-09-25",
|
||||||
"timezone": "Asia/Shanghai",
|
"timezone": "Asia/Shanghai",
|
||||||
"started_at": "2026-09-18T02:11:39.967012+00:00",
|
"status": "partial_sync_with_source_and_media_gaps",
|
||||||
"collection_finished_at": "2026-09-18T02:16:36.188053+00:00",
|
"started_at": "2026-09-25T01:12:33.054002+00:00",
|
||||||
"previous_successful_collection_finished_at": "2026-09-11T01:06:33.369594+00:00",
|
"collection_finished_at": "2026-09-25T01:16:29.789990+00:00",
|
||||||
"raw_visible_posts": 1771,
|
"previous_successful_collection_finished_at": "2026-09-18T02:16:36.188053+00:00",
|
||||||
"raw_list_pages": 18,
|
"raw_visible_posts": 1859,
|
||||||
|
"raw_list_pages": 19,
|
||||||
"excluded_older_posts": 1101,
|
"excluded_older_posts": 1101,
|
||||||
"retained_posts": 670,
|
"retained_posts": 758,
|
||||||
"retained_comments": 9601,
|
"retained_comments": 10141,
|
||||||
"newly_indexed_posts": 41,
|
"indexed_posts_including_unavailable": 759,
|
||||||
"newly_indexed_posts_created_after_previous_collection": 29,
|
"historical_comments_retained_for_unavailable_posts": 9,
|
||||||
"historical_posts_newly_indexed": 12,
|
"newly_indexed_posts": 89,
|
||||||
"comments_on_newly_indexed_posts": 5,
|
"newly_indexed_posts_created_after_previous_collection": 72,
|
||||||
"newly_visible_comments_on_existing_posts": 9,
|
"historical_posts_newly_indexed": 17,
|
||||||
"historical_comments_newly_visible": 7,
|
"comments_on_newly_indexed_posts": 9,
|
||||||
"newly_visible_comments_created_after_previous_collection": 7,
|
"newly_visible_comments_on_existing_posts": 540,
|
||||||
"comment_count_net_change": 14,
|
"historical_comments_newly_visible": 482,
|
||||||
"metadata_changed_existing_posts": 231,
|
"newly_visible_comments_created_after_previous_collection": 67,
|
||||||
"body_changed_existing_post_ids": [
|
"comment_count_net_change": 540,
|
||||||
"43320471694999",
|
"metadata_changed_existing_posts": 329,
|
||||||
"43030071187479"
|
"body_changed_existing_post_ids": [],
|
||||||
],
|
|
||||||
"comment_changed_existing_post_ids": [
|
"comment_changed_existing_post_ids": [
|
||||||
"42302637812503"
|
"37427295710359",
|
||||||
|
"37473718017175",
|
||||||
|
"37759477785239",
|
||||||
|
"37857879534487",
|
||||||
|
"37858237686167",
|
||||||
|
"38120459173143",
|
||||||
|
"38151220803095",
|
||||||
|
"38669179039255",
|
||||||
|
"38767793594775",
|
||||||
|
"39118594803095",
|
||||||
|
"39183798681239",
|
||||||
|
"39215982466071",
|
||||||
|
"39225718749591",
|
||||||
|
"39249078858647",
|
||||||
|
"39304762113815",
|
||||||
|
"39319955780887",
|
||||||
|
"39367839107095",
|
||||||
|
"39567420541079",
|
||||||
|
"39729140385559",
|
||||||
|
"39756841978519",
|
||||||
|
"39845230299927",
|
||||||
|
"39873247817495",
|
||||||
|
"39922788056343",
|
||||||
|
"40031467208983",
|
||||||
|
"40089426663959",
|
||||||
|
"40287368788375",
|
||||||
|
"40373405530647",
|
||||||
|
"40544447671959",
|
||||||
|
"40734437350679",
|
||||||
|
"40734489248151",
|
||||||
|
"40737593669911",
|
||||||
|
"40790666727575",
|
||||||
|
"40847481080087",
|
||||||
|
"40936186021271",
|
||||||
|
"40962462282007",
|
||||||
|
"40964044763031",
|
||||||
|
"41020681850519",
|
||||||
|
"41065497021335",
|
||||||
|
"41138382717719",
|
||||||
|
"41162636720919",
|
||||||
|
"41236357050903",
|
||||||
|
"41318082888343",
|
||||||
|
"41320039211671",
|
||||||
|
"41363895396247",
|
||||||
|
"41391880122007",
|
||||||
|
"41392995550231",
|
||||||
|
"41396933582231",
|
||||||
|
"41397704497303",
|
||||||
|
"41417418697111",
|
||||||
|
"41418196103191",
|
||||||
|
"41420742165527",
|
||||||
|
"41459028441623",
|
||||||
|
"41510344038551",
|
||||||
|
"41540700959639",
|
||||||
|
"41550783184663",
|
||||||
|
"41551045362071",
|
||||||
|
"41551682155799",
|
||||||
|
"41552805539991",
|
||||||
|
"41553665016983",
|
||||||
|
"41556669366551",
|
||||||
|
"41567966789655",
|
||||||
|
"41625263438999",
|
||||||
|
"41651283176855",
|
||||||
|
"41654205318807",
|
||||||
|
"41655387004439",
|
||||||
|
"41684104419095",
|
||||||
|
"41692781472919",
|
||||||
|
"41706827651991",
|
||||||
|
"41719890581911",
|
||||||
|
"41732813691287",
|
||||||
|
"41801462727191",
|
||||||
|
"41804720051607",
|
||||||
|
"41825836449047",
|
||||||
|
"41849925214231",
|
||||||
|
"41854026316055",
|
||||||
|
"41856507417623",
|
||||||
|
"41877348653335",
|
||||||
|
"41877985926423",
|
||||||
|
"41878175264663",
|
||||||
|
"41880799695127",
|
||||||
|
"41881412101399",
|
||||||
|
"41897133795479",
|
||||||
|
"41898002651287",
|
||||||
|
"41898564566167",
|
||||||
|
"41903649174807",
|
||||||
|
"41908323673111",
|
||||||
|
"41909117583383",
|
||||||
|
"41937689411479",
|
||||||
|
"41939959839895",
|
||||||
|
"41949669579031",
|
||||||
|
"41974186627351",
|
||||||
|
"41992899715607",
|
||||||
|
"41993968062743",
|
||||||
|
"41994809192215",
|
||||||
|
"41995369186455",
|
||||||
|
"41995684431767",
|
||||||
|
"42007074836247",
|
||||||
|
"42015743397655",
|
||||||
|
"42018671130391",
|
||||||
|
"42019010387479",
|
||||||
|
"42019332809111",
|
||||||
|
"42019924663319",
|
||||||
|
"42020342676759",
|
||||||
|
"42021244122775",
|
||||||
|
"42021722069143",
|
||||||
|
"42022555985047",
|
||||||
|
"42029118285847",
|
||||||
|
"42045578950551",
|
||||||
|
"42047259261335",
|
||||||
|
"42060935441943",
|
||||||
|
"42061123738903",
|
||||||
|
"42062231758103",
|
||||||
|
"42064544905495",
|
||||||
|
"42064574062359",
|
||||||
|
"42065262268567",
|
||||||
|
"42065802192023",
|
||||||
|
"42078729821591",
|
||||||
|
"42078917602455",
|
||||||
|
"42160836246039",
|
||||||
|
"42160945573271",
|
||||||
|
"42161798745879",
|
||||||
|
"42184733027991",
|
||||||
|
"42185098677399",
|
||||||
|
"42187744015767",
|
||||||
|
"42187906212247",
|
||||||
|
"42211897526295",
|
||||||
|
"42215210781719",
|
||||||
|
"42215740602135",
|
||||||
|
"42216209199255",
|
||||||
|
"42259186135831",
|
||||||
|
"42274443124119",
|
||||||
|
"42302637812503",
|
||||||
|
"42359440646551",
|
||||||
|
"42406158561943",
|
||||||
|
"42407253630359",
|
||||||
|
"42423399281815",
|
||||||
|
"42423993843735",
|
||||||
|
"42455010729367",
|
||||||
|
"42460800447895",
|
||||||
|
"42487308197783",
|
||||||
|
"42509573118999",
|
||||||
|
"42513455206423",
|
||||||
|
"42513832186263",
|
||||||
|
"42519002511767",
|
||||||
|
"42549574041367",
|
||||||
|
"42585946296855",
|
||||||
|
"42587179348119",
|
||||||
|
"42662873714711",
|
||||||
|
"42686571665303",
|
||||||
|
"42691768951447",
|
||||||
|
"42714622464407",
|
||||||
|
"42720875852439",
|
||||||
|
"42721824181271",
|
||||||
|
"42738237663639",
|
||||||
|
"42740521405463",
|
||||||
|
"42743732814999",
|
||||||
|
"42756136847639",
|
||||||
|
"42756641286295",
|
||||||
|
"42757091114647",
|
||||||
|
"42771962021143",
|
||||||
|
"42772611660823",
|
||||||
|
"42774450536343",
|
||||||
|
"42775887653399",
|
||||||
|
"42801904467607",
|
||||||
|
"42945861800983",
|
||||||
|
"42955680180247",
|
||||||
|
"42972530812183",
|
||||||
|
"42975079714839",
|
||||||
|
"42981290349463",
|
||||||
|
"43002825233687",
|
||||||
|
"43006316208151",
|
||||||
|
"43030071187479",
|
||||||
|
"43031425132823",
|
||||||
|
"43031577079575",
|
||||||
|
"43058251853847",
|
||||||
|
"43067045830679",
|
||||||
|
"43085454784535",
|
||||||
|
"43087031637527",
|
||||||
|
"43147371557015",
|
||||||
|
"43151729306647",
|
||||||
|
"43178926004887",
|
||||||
|
"43179521660823",
|
||||||
|
"43179860065303",
|
||||||
|
"43181411147287",
|
||||||
|
"43208281917591",
|
||||||
|
"43269155035799",
|
||||||
|
"43287990820375",
|
||||||
|
"43299585796247",
|
||||||
|
"43309848501783",
|
||||||
|
"43319649278359",
|
||||||
|
"43319751480599",
|
||||||
|
"43320365312023",
|
||||||
|
"43320471694999",
|
||||||
|
"43322521365143",
|
||||||
|
"43322749748503",
|
||||||
|
"43383826751127",
|
||||||
|
"43410200668823",
|
||||||
|
"43411841446423",
|
||||||
|
"43438866840343",
|
||||||
|
"43467733543319",
|
||||||
|
"43477475833623",
|
||||||
|
"43498120902807",
|
||||||
|
"43499065604247",
|
||||||
|
"43500671638551",
|
||||||
|
"43501264459799",
|
||||||
|
"43502970330647",
|
||||||
|
"43508107778711",
|
||||||
|
"43524498271895",
|
||||||
|
"43524503109655",
|
||||||
|
"43527309427863",
|
||||||
|
"43527457608087",
|
||||||
|
"43529198653719",
|
||||||
|
"43529216017047",
|
||||||
|
"43536370604567",
|
||||||
|
"43541109631895",
|
||||||
|
"43556223197335",
|
||||||
|
"43561782887575"
|
||||||
],
|
],
|
||||||
"edited_existing_comments": 0,
|
"edited_existing_comments": 0,
|
||||||
"missing_existing_comment_ids": 0,
|
"missing_existing_comment_ids_on_visible_posts": 0,
|
||||||
"failed_comment_records": 0,
|
"failed_comment_records": 0,
|
||||||
"missing_previously_indexed_posts": 0,
|
"missing_previously_indexed_posts": 1,
|
||||||
"posts_cited_in_knowledge": 142,
|
"unavailable_posts": [
|
||||||
|
{
|
||||||
|
"post_id": "41208709211671",
|
||||||
|
"direct_api_status": 404,
|
||||||
|
"historical_comments_retained": 9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"posts_cited_in_knowledge": 154,
|
||||||
"groups": {
|
"groups": {
|
||||||
"portfolio": 50,
|
"guides": 17,
|
||||||
"ai": 154,
|
"ai": 160,
|
||||||
"ideas": 30,
|
"ideas": 40,
|
||||||
"lessons": 134,
|
"engineering": 86,
|
||||||
"optimization": 62,
|
"general": 137,
|
||||||
"data": 20,
|
"lessons": 141,
|
||||||
"events": 41,
|
"optimization": 65,
|
||||||
"engineering": 75,
|
"events": 42,
|
||||||
"guides": 8,
|
"portfolio": 51,
|
||||||
"general": 96
|
"data": 20
|
||||||
},
|
},
|
||||||
"new_post_texts_screened": 40,
|
"new_post_texts_screened": 89,
|
||||||
"metadata_only_post_ids": [
|
"metadata_only_post_ids": [
|
||||||
"43287990820375"
|
"43287990820375"
|
||||||
],
|
],
|
||||||
"new_or_newly_visible_comments_read": 14,
|
"new_or_newly_visible_comments_screened": 549,
|
||||||
"official_articles_checked": [
|
"official_articles_checked": [],
|
||||||
{
|
|
||||||
"id": "41681208900759",
|
|
||||||
"updated_at": "2026-09-13T15:38:21Z",
|
|
||||||
"read_at": "2026-09-18"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"baseline_state_directory": ".local/forum-sync/",
|
"baseline_state_directory": ".local/forum-sync/",
|
||||||
"limits": [
|
|
||||||
"Newly visible records can predate the previous run; cause of historical visibility gaps is unknown.",
|
|
||||||
"One new post is image-only and its body has not been visually reviewed; other images and attachments not fully verified.",
|
|
||||||
"Community backtest claims not reproduced; code findings are static, not live platform tests.",
|
|
||||||
"No simulations, submissions, email sync, public publishing, or credential/session persistence.",
|
|
||||||
"Comment fingerprints cover normalized text only, not link destinations, formatting, or embedded media changes."
|
|
||||||
],
|
|
||||||
"unread_media": [
|
"unread_media": [
|
||||||
{
|
{
|
||||||
"post_id": "43287990820375",
|
"post_id": "43287990820375",
|
||||||
"image_count": 12,
|
"image_count": 12,
|
||||||
|
"images_retried_this_run": 1,
|
||||||
"http_status": 403
|
"http_status": 403
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"limits": [
|
||||||
|
"758 currently visible posts and 10141 freshly retrieved comments; indexes additionally retain one unavailable post with nine historical comments.",
|
||||||
|
"One previously indexed post returned HTTP 404; its previous fingerprint and records are retained for retry, not deleted or marked freshly complete.",
|
||||||
|
"Newly visible historical posts/comments are backfill; the reason for prior visibility gaps is unknown.",
|
||||||
|
"One image-only post remains unread; one of its twelve official images was retried and returned HTTP 403.",
|
||||||
|
"Comment fingerprints cover normalized text only, not link targets, formatting or media-only changes.",
|
||||||
|
"Community claims and code were reviewed but not executed or backtested; no current platform rules were inferred from comments.",
|
||||||
|
"No email sync, simulation, external publication, submission, credential/session persistence or Git commit/push."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"forum_sync_history": [
|
"forum_sync_history": [
|
||||||
@@ -150,6 +370,78 @@
|
|||||||
"All scoped comments fetched, selective historical body reading; images and attachments not fully verified.",
|
"All scoped comments fetched, selective historical body reading; images and attachments not fully verified.",
|
||||||
"No simulations, submissions, email sync, public publishing, or credential/session persistence."
|
"No simulations, submissions, email sync, public publishing, or credential/session persistence."
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collected_at": "2026-09-18",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"started_at": "2026-09-18T02:11:39.967012+00:00",
|
||||||
|
"collection_finished_at": "2026-09-18T02:16:36.188053+00:00",
|
||||||
|
"previous_successful_collection_finished_at": "2026-09-11T01:06:33.369594+00:00",
|
||||||
|
"raw_visible_posts": 1771,
|
||||||
|
"raw_list_pages": 18,
|
||||||
|
"excluded_older_posts": 1101,
|
||||||
|
"retained_posts": 670,
|
||||||
|
"retained_comments": 9601,
|
||||||
|
"newly_indexed_posts": 41,
|
||||||
|
"newly_indexed_posts_created_after_previous_collection": 29,
|
||||||
|
"historical_posts_newly_indexed": 12,
|
||||||
|
"comments_on_newly_indexed_posts": 5,
|
||||||
|
"newly_visible_comments_on_existing_posts": 9,
|
||||||
|
"historical_comments_newly_visible": 7,
|
||||||
|
"newly_visible_comments_created_after_previous_collection": 7,
|
||||||
|
"comment_count_net_change": 14,
|
||||||
|
"metadata_changed_existing_posts": 231,
|
||||||
|
"body_changed_existing_post_ids": [
|
||||||
|
"43320471694999",
|
||||||
|
"43030071187479"
|
||||||
|
],
|
||||||
|
"comment_changed_existing_post_ids": [
|
||||||
|
"42302637812503"
|
||||||
|
],
|
||||||
|
"edited_existing_comments": 0,
|
||||||
|
"missing_existing_comment_ids": 0,
|
||||||
|
"failed_comment_records": 0,
|
||||||
|
"missing_previously_indexed_posts": 0,
|
||||||
|
"posts_cited_in_knowledge": 142,
|
||||||
|
"groups": {
|
||||||
|
"portfolio": 50,
|
||||||
|
"ai": 154,
|
||||||
|
"ideas": 30,
|
||||||
|
"lessons": 134,
|
||||||
|
"optimization": 62,
|
||||||
|
"data": 20,
|
||||||
|
"events": 41,
|
||||||
|
"engineering": 75,
|
||||||
|
"guides": 8,
|
||||||
|
"general": 96
|
||||||
|
},
|
||||||
|
"new_post_texts_screened": 40,
|
||||||
|
"metadata_only_post_ids": [
|
||||||
|
"43287990820375"
|
||||||
|
],
|
||||||
|
"new_or_newly_visible_comments_read": 14,
|
||||||
|
"official_articles_checked": [
|
||||||
|
{
|
||||||
|
"id": "41681208900759",
|
||||||
|
"updated_at": "2026-09-13T15:38:21Z",
|
||||||
|
"read_at": "2026-09-18"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"baseline_state_directory": ".local/forum-sync/",
|
||||||
|
"limits": [
|
||||||
|
"Newly visible records can predate the previous run; cause of historical visibility gaps is unknown.",
|
||||||
|
"One new post is image-only and its body has not been visually reviewed; other images and attachments not fully verified.",
|
||||||
|
"Community backtest claims not reproduced; code findings are static, not live platform tests.",
|
||||||
|
"No simulations, submissions, email sync, public publishing, or credential/session persistence.",
|
||||||
|
"Comment fingerprints cover normalized text only, not link destinations, formatting, or embedded media changes."
|
||||||
|
],
|
||||||
|
"unread_media": [
|
||||||
|
{
|
||||||
|
"post_id": "43287990820375",
|
||||||
|
"image_count": 12,
|
||||||
|
"http_status": 403
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -45,3 +45,11 @@
|
|||||||
增加同类字段可能只是叠加相关噪声和缺失。帖子记录了同域克隆失败、跨类别组合更好的样本,但没有证明多类别组合必然有效。来源:[字段堆越多alpha越牛?我替你们试过了,真不是这么回事](https://support.worldquantbrain.com/hc/en-us/community/posts/41908323673111)(2026-07-13)。
|
增加同类字段可能只是叠加相关噪声和缺失。帖子记录了同域克隆失败、跨类别组合更好的样本,但没有证明多类别组合必然有效。来源:[字段堆越多alpha越牛?我替你们试过了,真不是这么回事](https://support.worldquantbrain.com/hc/en-us/community/posts/41908323673111)(2026-07-13)。
|
||||||
|
|
||||||
保持主字段、方向与覆盖语义后,才讨论删除冗余算子;降低 OpCount 不能靠偷偷改变信号。来源:[【Quant101】OpCount 拯救计划:因子化简](https://support.worldquantbrain.com/hc/en-us/community/posts/41105918774807)(2026-06-10)。详见 [优化流程](../08-alpha-optimization/diagnostic-workflow.md)。
|
保持主字段、方向与覆盖语义后,才讨论删除冗余算子;降低 OpCount 不能靠偷偷改变信号。来源:[【Quant101】OpCount 拯救计划:因子化简](https://support.worldquantbrain.com/hc/en-us/community/posts/41105918774807)(2026-06-10)。详见 [优化流程](../08-alpha-optimization/diagnostic-workflow.md)。
|
||||||
|
|
||||||
|
## 2026-09-25 补充:缺失设置是实验变量
|
||||||
|
|
||||||
|
[按数据类型选择回填](https://support.worldquantbrain.com/hc/en-us/community/posts/43273417454487)(2026-09-05,本轮补录)和 [NanHandling 讨论](https://support.worldquantbrain.com/hc/en-us/community/posts/43562519639063)(2026-09-17,本轮补录)提醒,事件未发生、状态尚未更新与采集缺失不能用同一填法。NanHandling ON/OFF、算子及字段组合可能改变有效股票数和信号含义;文中观测不是平台所有算子的统一定义,尤其不能推广“其他情况无所谓”。
|
||||||
|
|
||||||
|
[新闻密度帖的新增可见评论](https://support.worldquantbrain.com/hc/en-us/community/posts/43541109631895#community_comment_43582240044695)(评论发表于 2026-09-18)提出:若无新闻日为缺失且均值忽略缺失,得到的是有新闻日的条件均值,并非每个交易日的新闻密度。应先核对实际返回值,再比较零填充、原缺失和有限回填,同时观察覆盖与集中度;不能预先把缺失都当无事件。[预处理讨论评论](https://support.worldquantbrain.com/hc/en-us/community/posts/43527457608087#community_comment_43552980010775)(2026-09-17,本轮补齐)进一步提醒,回填后的 Sharpe 提升可能只是股票构成改变,需并看信号年龄、多空数量、换手及分组暴露。这些是评论者的机制建议,不是已确认因果结果。
|
||||||
|
|
||||||
|
历史频域来源 41208709211671 在本次列表缺失,2026-09-25 直接 API 请求返回 404;保留此前归纳和来源,不据此判断作者删除或内容失效。
|
||||||
|
|||||||
@@ -33,3 +33,9 @@
|
|||||||
Osmosis 社区帖称有多个 Region×Delay、每赛道 Alpha 数量与点数要求;具体资格和锁定时区未由本轮完整核实,不将该帖直接升级为当前官方规则。来源:[🌟 玩转 Osmosis:让你的 Alphas 成为最高 2 倍收益放大器!(机制详解与实操指南)](https://support.worldquantbrain.com/hc/en-us/community/posts/43002825233687)(2026-08-26)。
|
Osmosis 社区帖称有多个 Region×Delay、每赛道 Alpha 数量与点数要求;具体资格和锁定时区未由本轮完整核实,不将该帖直接升级为当前官方规则。来源:[🌟 玩转 Osmosis:让你的 Alphas 成为最高 2 倍收益放大器!(机制详解与实操指南)](https://support.worldquantbrain.com/hc/en-us/community/posts/43002825233687)(2026-08-26)。
|
||||||
|
|
||||||
关于低 PC 与高 Fitness 的取舍,社区有单变量实验支持按质量预算思考,不能只追最低 PC。来源:[关于降Prod Correlation的实践(一)](https://support.worldquantbrain.com/hc/en-us/community/posts/39118594803095)(2026-03-18)。具体权重和阈值需要依据你的池验证,本任务未修改组合、提交 Alpha 或分配点数。
|
关于低 PC 与高 Fitness 的取舍,社区有单变量实验支持按质量预算思考,不能只追最低 PC。来源:[关于降Prod Correlation的实践(一)](https://support.worldquantbrain.com/hc/en-us/community/posts/39118594803095)(2026-03-18)。具体权重和阈值需要依据你的池验证,本任务未修改组合、提交 Alpha 或分配点数。
|
||||||
|
|
||||||
|
## 2026-09-25 补充:聚类代表不能替代每个候选的终验
|
||||||
|
|
||||||
|
[本地 PnL 聚类预筛工具](https://support.worldquantbrain.com/hc/en-us/community/posts/43300341649687)(2026-09-07,本轮补录)按候选间相关性合并簇,只查代表项并把 PC 结果传播给成员。减少重复检查可作为调度思路,但 PASS/FAIL 传播没有充分保证。以同一日期样本上中心化、标准化的 A、B、P 为例,柯西不等式给出 |corr(A,P)−corr(B,P)| ≤ √(2(1−corr(A,B)));即使 corr(A,B)=0.9,上界仍约 0.447,远不能保证跨不过提交门槛。这是本库数学边界,不是平台 PC 公式;链式连通聚类还不保证任意两成员都达到聚类阈值。
|
||||||
|
|
||||||
|
[双桶去相关案例](https://support.worldquantbrain.com/hc/en-us/community/posts/43624302417559)(2026-09-20)报告 PC 下降同时 margin 下降,也给出分桶轴与信号重叠导致信号被抹去的反例。因此比较应同时记录成本、覆盖、子区域与生产相关性,不能只凭某项过线判定改善。[Value Factor 讨论](https://support.worldquantbrain.com/hc/en-us/community/posts/43589077012759)(2026-09-18)区分单体质量、自有池增量和平台全池评价;其教学示例不是实测收益或支付公式。上述数值和工具效果均未复现。
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# WorldQuant 研究重启知识库
|
# WorldQuant 研究重启知识库
|
||||||
|
|
||||||
首次整理:2026-09-07;论坛最近同步:2026-09-18,Asia/Shanghai。论坛严格按 **2026-01-01 起的发帖时间**筛选;旧帖即使今年更新、被邮件或合集引用,也不纳入论坛研究来源。面向个人研究,不包含已运行或保证有效的 Alpha。
|
首次整理:2026-09-07;论坛最近同步:2026-09-25(有访问缺口),Asia/Shanghai。论坛严格按 **2026-01-01 起的发帖时间**筛选;旧帖即使今年更新、被邮件或合集引用,也不纳入论坛研究来源。面向个人研究,不包含已运行或保证有效的 Alpha。
|
||||||
|
|
||||||
9 月 18 日增量:论坛索引现有 **670 篇帖子、9,601 条评论**。本次新收录 41 篇,其中 29 篇发表于上次采集之后、12 篇为历史补录;通过内容指纹确认 2 篇旧帖正文更新、1 篇旧帖出现新可见评论。新增[差分与类别编码边界](01-alpha-ideas/relationship-and-extension-cards.md)、[代理变量与样本边界](01-alpha-ideas/data-template-cards.md)、[回测恢复风险](03-engineering/2026-operating-lessons.md),并复核 [SPC 当前输出规范](04-platform/spc.md)。一篇图片正文尚未精读,其余覆盖及计数口径见 [来源与覆盖](06-sources/README.md)。
|
9 月 25 日增量:本次实际取得 **758 篇可见帖子、10,141 条评论**。新收录 89 篇,其中 72 篇发表于上次采集后、17 篇为历史补录;217 篇旧帖出现新可见评论,未见既有正文或评论文本编辑。更新[时间前推验证](05-research-practice/validation.md)、[FFT/PCA 实现边界](02-ai-mining/python-alpha.md)、[相关性预筛](08-alpha-optimization/portfolio-and-correlation.md)、[数据语义](07-professional-knowledge/data-and-signal-semantics.md)、[工程状态](03-engineering/2026-operating-lessons.md)和[事件信号对照](01-alpha-ideas/data-template-cards.md)。另有 1 篇旧帖返回 404,历史记录仍保留,索引共 759 篇;1 篇图片正文继续待读。详见[来源与覆盖](06-sources/README.md)。
|
||||||
|
|
||||||
**先更新研究方向,再恢复回测规模。** 当前最值得投入的是有经济解释的低相关信号、能保留失败证据的 AI 实验闭环,以及组合层的增量价值。不要把“提交数量”“Token 消耗”当作研究进展。
|
**先更新研究方向,再恢复回测规模。** 当前最值得投入的是有经济解释的低相关信号、能保留失败证据的 AI 实验闭环,以及组合层的增量价值。不要把“提交数量”“Token 消耗”当作研究进展。
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Pull and deploy on server B with configuration injected over SSH by Gitea.
|
# Deploy on the target Linux Docker host with configuration injected by Gitea.
|
||||||
# Returns nonzero on configuration/pull/migration/health failure. Never removes data.
|
# Returns nonzero on configuration/build/migration/health failure. Never removes data.
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
umask 077
|
umask 077
|
||||||
|
|
||||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
# Fail before touching the host; never read a checkout's local .env file.
|
# Fail before touching the host; never read a checkout's local .env file.
|
||||||
for key in IMAGE_PREFIX DEPLOY_TAG REGISTRY_USERNAME REGISTRY_PASSWORD WQ_EMAIL WQ_PASSWORD DATABASE_URL ADMIN_PASSWORD ENCRYPTION_KEY PUBLIC_ORIGIN; do
|
for key in WQ_EMAIL WQ_PASSWORD DATABASE_URL ADMIN_PASSWORD ENCRYPTION_KEY PUBLIC_ORIGIN; do
|
||||||
if [[ -z "${!key:-}" ]]; then
|
if [[ -z "${!key:-}" ]]; then
|
||||||
echo "Missing required Gitea configuration: $key" >&2
|
echo "Missing required Gitea configuration: $key" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
export DEPLOY_TAG="${DEPLOY_TAG:-$(git rev-parse HEAD)}"
|
||||||
compose=(docker compose --env-file /dev/null -p wq-alpha-production -f compose.production.yaml)
|
compose=(docker compose --env-file /dev/null -p wq-alpha-production -f compose.production.yaml)
|
||||||
lock_id=""
|
lock_id=""
|
||||||
auth_dir=""
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
local rc=$?
|
local rc=$?
|
||||||
"${compose[@]}" --profile jobs ps -a || true
|
"${compose[@]}" --profile jobs ps -a || true
|
||||||
@@ -22,34 +22,25 @@ cleanup() {
|
|||||||
if [[ -n "$lock_id" ]]; then
|
if [[ -n "$lock_id" ]]; then
|
||||||
docker rm "$lock_id" >/dev/null || true
|
docker rm "$lock_id" >/dev/null || true
|
||||||
fi
|
fi
|
||||||
if [[ -n "$auth_dir" ]]; then
|
|
||||||
rm -rf -- "$auth_dir"
|
|
||||||
fi
|
|
||||||
exit "$rc"
|
exit "$rc"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
trap 'exit 130' INT
|
trap 'exit 130' INT
|
||||||
trap 'exit 143' TERM
|
trap 'exit 143' TERM
|
||||||
trap 'exit 129' HUP
|
|
||||||
|
|
||||||
"${compose[@]}" config --quiet
|
"${compose[@]}" config --quiet
|
||||||
"${compose[@]}" --profile jobs config --quiet
|
"${compose[@]}" --profile jobs config --quiet
|
||||||
auth_dir=$(mktemp -d)
|
"${compose[@]}" build backend web
|
||||||
export DOCKER_CONFIG="$auth_dir"
|
|
||||||
printf '%s' "$REGISTRY_PASSWORD" | docker login "${IMAGE_PREFIX%%/*}" --username "$REGISTRY_USERNAME" --password-stdin
|
|
||||||
unset REGISTRY_PASSWORD
|
|
||||||
# Download both images before stopping anything; migration uses the backend image.
|
|
||||||
"${compose[@]}" pull --policy always backend web
|
|
||||||
# Docker enforces unique container names across runner jobs and checkout paths.
|
# Docker enforces unique container names across runner jobs and checkout paths.
|
||||||
# The lock container is never started and receives no deployment credentials.
|
# The lock container is never started and receives no deployment credentials.
|
||||||
if ! lock_id=$(docker create --name wq-alpha-production-deploy-lock \
|
if ! lock_id=$(docker create --name wq-alpha-production-deploy-lock \
|
||||||
--label "wq.deploy.commit=$DEPLOY_TAG" \
|
--label "wq.deploy.commit=$DEPLOY_TAG" \
|
||||||
--network none --entrypoint /bin/true "$IMAGE_PREFIX-backend:$DEPLOY_TAG"); then
|
--network none --entrypoint /bin/true "wq-alpha-production-backend:$DEPLOY_TAG"); then
|
||||||
echo 'Cannot acquire deployment lock; check Docker and other active deployments.' >&2
|
echo 'Cannot acquire deployment lock; check Docker and other active deployments.' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Validate secrets and DB connectivity before interrupting the running version.
|
# Validate secrets and DB connectivity before interrupting the running version.
|
||||||
"${compose[@]}" run --rm --no-deps --pull never -T backend python -c '
|
"${compose[@]}" run --rm --no-deps backend python -c '
|
||||||
import asyncio
|
import asyncio
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -75,6 +66,6 @@ if [[ -n "$previous_images" ]]; then
|
|||||||
docker image inspect --format '{{.Id}} {{json .RepoTags}}' $previous_images
|
docker image inspect --format '{{.Id}} {{json .RepoTags}}' $previous_images
|
||||||
fi
|
fi
|
||||||
"${compose[@]}" stop web backend
|
"${compose[@]}" stop web backend
|
||||||
"${compose[@]}" --profile jobs run --rm --no-deps --pull never -T migrate
|
"${compose[@]}" --profile jobs run --rm --no-deps migrate
|
||||||
"${compose[@]}" up -d --no-build --pull never --remove-orphans --wait --wait-timeout 180 backend web
|
"${compose[@]}" up -d --no-build --remove-orphans --wait --wait-timeout 180 backend web
|
||||||
echo "Production is healthy; release $DEPLOY_TAG"
|
echo "Production is healthy; release $DEPLOY_TAG"
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Send a release bundle and environment to server B. No application .env is written.
|
|
||||||
# Requires Bash/OpenSSH locally and Bash/tar/base64/Docker Compose on server B.
|
|
||||||
# Returns the remote deployment status; credentials never appear in SSH arguments.
|
|
||||||
set -Eeuo pipefail
|
|
||||||
umask 077
|
|
||||||
|
|
||||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
|
||||||
for key in PROD_HOST PROD_USER DEPLOY_PATH PROD_SSH_KEY PROD_KNOWN_HOSTS IMAGE_PREFIX DEPLOY_TAG REGISTRY_USERNAME REGISTRY_PASSWORD WQ_EMAIL WQ_PASSWORD DATABASE_URL ADMIN_PASSWORD ENCRYPTION_KEY PUBLIC_ORIGIN; do
|
|
||||||
if [[ -z "${!key:-}" ]]; then
|
|
||||||
echo "Missing required Gitea configuration: $key" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [[ "$DEPLOY_PATH" != /* || ! "$DEPLOY_TAG" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$ ]]; then
|
|
||||||
echo 'DEPLOY_PATH must be absolute and DEPLOY_TAG must be a valid Docker tag.' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
ssh_dir=$(mktemp -d)
|
|
||||||
trap 'rm -rf -- "$ssh_dir"' EXIT
|
|
||||||
trap 'exit 130' INT
|
|
||||||
trap 'exit 143' TERM
|
|
||||||
trap 'exit 129' HUP
|
|
||||||
printf '%s\n' "$PROD_SSH_KEY" > "$ssh_dir/id"
|
|
||||||
printf '%s\n' "$PROD_KNOWN_HOSTS" > "$ssh_dir/known_hosts"
|
|
||||||
unset PROD_SSH_KEY PROD_KNOWN_HOSTS
|
|
||||||
ssh_options=(
|
|
||||||
-F /dev/null -T
|
|
||||||
-i "$ssh_dir/id"
|
|
||||||
-p "${PROD_PORT:-22}"
|
|
||||||
-l "$PROD_USER"
|
|
||||||
-o BatchMode=yes
|
|
||||||
-o IdentitiesOnly=yes
|
|
||||||
-o StrictHostKeyChecking=yes
|
|
||||||
-o "UserKnownHostsFile=$ssh_dir/known_hosts"
|
|
||||||
-o GlobalKnownHostsFile=/dev/null
|
|
||||||
-o ConnectTimeout=15
|
|
||||||
-o ServerAliveInterval=15
|
|
||||||
-o ServerAliveCountMax=4
|
|
||||||
)
|
|
||||||
|
|
||||||
# Bash %q preserves quotes, dollar signs and newlines without evaluating values.
|
|
||||||
# Only this allowlist crosses SSH; private keys and the runner's environment stay local.
|
|
||||||
{
|
|
||||||
printf 'set -Eeuo pipefail\numask 077\n'
|
|
||||||
for key in DEPLOY_PATH IMAGE_PREFIX DEPLOY_TAG REGISTRY_USERNAME REGISTRY_PASSWORD WQ_EMAIL WQ_PASSWORD DATABASE_URL ADMIN_PASSWORD ENCRYPTION_KEY PUBLIC_ORIGIN ADMIN_USERNAME MCP_ENABLED; do
|
|
||||||
printf 'export %s=%q\n' "$key" "${!key:-}"
|
|
||||||
done
|
|
||||||
cat <<'REMOTE'
|
|
||||||
mkdir -p -- "$DEPLOY_PATH/releases"
|
|
||||||
release_dir=$(mktemp -d "$DEPLOY_PATH/releases/$DEPLOY_TAG.XXXXXX")
|
|
||||||
cd "$release_dir"
|
|
||||||
base64 -d <<'WQ_RELEASE_BUNDLE' | tar -xzf -
|
|
||||||
REMOTE
|
|
||||||
# A small base64 archive lets one SSH connection carry files and shell-quoted env.
|
|
||||||
# Each attempt gets its own directory, so competing jobs cannot overwrite files.
|
|
||||||
COPYFILE_DISABLE=1 tar -czf - compose.production.yaml scripts/deploy-production.sh | base64
|
|
||||||
printf '\nWQ_RELEASE_BUNDLE\n'
|
|
||||||
# Docker must not consume the SSH script input. B does not need a Git checkout.
|
|
||||||
printf 'exec bash scripts/deploy-production.sh </dev/null\n'
|
|
||||||
} | ssh "${ssh_options[@]}" -- "$PROD_HOST" 'bash --noprofile --norc -se'
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
"""Exercise deployment failure boundaries without Docker or network mutations.
|
|
||||||
|
|
||||||
Run with: python3 -m unittest discover -s scripts/tests -v
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
RUNTIME_KEYS = (
|
|
||||||
"IMAGE_PREFIX", "DEPLOY_TAG", "REGISTRY_USERNAME", "REGISTRY_PASSWORD",
|
|
||||||
"WQ_EMAIL", "WQ_PASSWORD", "DATABASE_URL", "ADMIN_PASSWORD", "ENCRYPTION_KEY",
|
|
||||||
"PUBLIC_ORIGIN",
|
|
||||||
)
|
|
||||||
|
|
||||||
DOCKER_STUB = r'''
|
|
||||||
import json, os, pathlib, sys
|
|
||||||
a = sys.argv[1:]
|
|
||||||
if a[0] == "compose":
|
|
||||||
if "config" in a: op = "config"
|
|
||||||
elif "pull" in a: op = "pull"
|
|
||||||
elif "run" in a: op = "migrate" if "migrate" in a else "preflight"
|
|
||||||
elif "images" in a: op = "images"
|
|
||||||
elif "stop" in a: op = "stop"
|
|
||||||
elif "up" in a: op = "up"
|
|
||||||
elif "ps" in a: op = "ps"
|
|
||||||
else: raise SystemExit("Unexpected compose invocation")
|
|
||||||
else:
|
|
||||||
op = {"create": "lock", "rm": "unlock", "image": "inspect"}.get(a[0], a[0])
|
|
||||||
entry = {"op": op, "args": a, "auth_dir": os.environ.get("DOCKER_CONFIG"),
|
|
||||||
"env": {k: os.environ.get(k) for k in
|
|
||||||
("ADMIN_PASSWORD", "WQ_PASSWORD", "DATABASE_URL", "DEPLOY_TAG")}}
|
|
||||||
if op == "login":
|
|
||||||
entry["password"] = sys.stdin.read()
|
|
||||||
auth = pathlib.Path(os.environ["DOCKER_CONFIG"])
|
|
||||||
(auth / "config.json").write_text("test credentials")
|
|
||||||
with open(os.environ["TEST_DOCKER_LOG"], "a") as out:
|
|
||||||
out.write(json.dumps(entry) + "\n")
|
|
||||||
if os.environ.get("TEST_FAIL") == op:
|
|
||||||
raise SystemExit(23)
|
|
||||||
if op == "lock": print("owned-lock-id")
|
|
||||||
if op == "images": print("sha256:previous-backend\nsha256:previous-web")
|
|
||||||
'''
|
|
||||||
|
|
||||||
SSH_STUB = r'''
|
|
||||||
import json, os, pathlib, stat, subprocess, sys
|
|
||||||
a = sys.argv[1:]
|
|
||||||
key = pathlib.Path(a[a.index("-i") + 1])
|
|
||||||
hosts = pathlib.Path(next(x.split("=", 1)[1] for x in a if x.startswith("UserKnownHostsFile=")))
|
|
||||||
pathlib.Path(os.environ["TEST_SSH_LOG"]).write_text(json.dumps({
|
|
||||||
"args": a, "key": key.read_text(), "hosts": hosts.read_text(),
|
|
||||||
"key_mode": stat.S_IMODE(key.stat().st_mode), "key_path": str(key),
|
|
||||||
}))
|
|
||||||
if os.environ.get("TEST_FAIL") == "ssh":
|
|
||||||
raise SystemExit(255)
|
|
||||||
# Simulate a different host: none of the deployment variables are inherited.
|
|
||||||
env = {k: v for k, v in os.environ.items() if k.startswith("TEST_") or k in ("PATH", "HOME", "TMPDIR")}
|
|
||||||
result = subprocess.run(["bash", "--noprofile", "--norc", "-se"],
|
|
||||||
input=sys.stdin.buffer.read(), env=env)
|
|
||||||
raise SystemExit(result.returncode)
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
class DeploymentTest(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.temp = tempfile.TemporaryDirectory()
|
|
||||||
self.addCleanup(self.temp.cleanup)
|
|
||||||
self.directory = Path(self.temp.name)
|
|
||||||
self.bin = self.directory / "bin"
|
|
||||||
self.bin.mkdir()
|
|
||||||
for name, code in (("docker", DOCKER_STUB), ("ssh", SSH_STUB)):
|
|
||||||
path = self.bin / name
|
|
||||||
path.write_text(f"#!{sys.executable}\n" + code)
|
|
||||||
path.chmod(0o700)
|
|
||||||
self.log = self.directory / "docker.jsonl"
|
|
||||||
self.ssh_log = self.directory / "ssh.json"
|
|
||||||
self.env = {
|
|
||||||
"PATH": str(self.bin) + os.pathsep + os.environ["PATH"],
|
|
||||||
"HOME": str(self.directory), "TMPDIR": str(self.directory),
|
|
||||||
"TEST_DOCKER_LOG": str(self.log), "TEST_SSH_LOG": str(self.ssh_log),
|
|
||||||
"IMAGE_PREFIX": "registry.example.test/team/wq", "DEPLOY_TAG": "abc123",
|
|
||||||
"REGISTRY_USERNAME": "publisher", "REGISTRY_PASSWORD": "fake-registry-token",
|
|
||||||
"WQ_EMAIL": "test@example.test", "WQ_PASSWORD": "fake-worldquant-password",
|
|
||||||
"DATABASE_URL": "postgresql+asyncpg://wq:fake@10.0.0.30:5432/wq",
|
|
||||||
"ADMIN_PASSWORD": "fake-admin-password", "ENCRYPTION_KEY": "fake-key",
|
|
||||||
"PUBLIC_ORIGIN": "https://alpha.example.test",
|
|
||||||
"PROD_HOST": "server-b.example.test", "PROD_USER": "deploy", "PROD_PORT": "2222",
|
|
||||||
"PROD_SSH_KEY": "fake-private-key\nsecond-line",
|
|
||||||
"PROD_KNOWN_HOSTS": "[server-b.example.test]:2222 ssh-ed25519 fake-public-key",
|
|
||||||
"DEPLOY_PATH": str(self.directory / "server B's project"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def run_script(self, script="deploy-production.sh", **overrides):
|
|
||||||
self.log.unlink(missing_ok=True)
|
|
||||||
self.ssh_log.unlink(missing_ok=True)
|
|
||||||
return subprocess.run(
|
|
||||||
["bash", str(ROOT / "scripts" / script)],
|
|
||||||
env=self.env | overrides, capture_output=True, text=True, timeout=20,
|
|
||||||
)
|
|
||||||
|
|
||||||
def calls(self):
|
|
||||||
return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else []
|
|
||||||
|
|
||||||
def assert_auth_cleaned(self):
|
|
||||||
for call in self.calls():
|
|
||||||
if call["auth_dir"]:
|
|
||||||
self.assertFalse(Path(call["auth_dir"]).exists())
|
|
||||||
|
|
||||||
def test_release_pulls_before_stopping_and_uses_only_local_images_afterward(self):
|
|
||||||
result = self.run_script()
|
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
|
||||||
calls = self.calls()
|
|
||||||
ops = [c["op"] for c in calls]
|
|
||||||
important = [op for op in ops if op in {"pull", "lock", "preflight", "stop", "migrate", "up", "unlock"}]
|
|
||||||
self.assertEqual(important, ["pull", "lock", "preflight", "stop", "migrate", "up", "unlock"])
|
|
||||||
for call in calls:
|
|
||||||
args = call["args"]
|
|
||||||
if call["op"] in {"preflight", "migrate", "up"}:
|
|
||||||
self.assertEqual(args[args.index("--pull") + 1], "never")
|
|
||||||
self.assertEqual(call["env"]["DATABASE_URL"], self.env["DATABASE_URL"])
|
|
||||||
self.assertNotIn("build", args)
|
|
||||||
if call["op"] == "unlock":
|
|
||||||
self.assertEqual(args, ["rm", "owned-lock-id"])
|
|
||||||
self.assertIn("Production is healthy", result.stdout)
|
|
||||||
self.assert_auth_cleaned()
|
|
||||||
|
|
||||||
def test_failures_before_switch_do_not_stop_running_services(self):
|
|
||||||
for stage in ("config", "login", "pull", "lock", "preflight"):
|
|
||||||
with self.subTest(stage=stage):
|
|
||||||
result = self.run_script(TEST_FAIL=stage)
|
|
||||||
self.assertNotEqual(result.returncode, 0)
|
|
||||||
ops = [c["op"] for c in self.calls()]
|
|
||||||
self.assertNotIn("stop", ops)
|
|
||||||
self.assertNotIn("migrate", ops)
|
|
||||||
self.assertNotIn("up", ops)
|
|
||||||
self.assertNotIn("Production is healthy", result.stdout)
|
|
||||||
self.assertEqual("unlock" in ops, stage == "preflight")
|
|
||||||
self.assert_auth_cleaned()
|
|
||||||
|
|
||||||
def test_migration_failure_does_not_start_application(self):
|
|
||||||
result = self.run_script(TEST_FAIL="migrate")
|
|
||||||
self.assertEqual(result.returncode, 23)
|
|
||||||
ops = [c["op"] for c in self.calls()]
|
|
||||||
self.assertIn("stop", ops)
|
|
||||||
self.assertNotIn("up", ops)
|
|
||||||
self.assertIn("unlock", ops)
|
|
||||||
self.assertNotIn("Production is healthy", result.stdout)
|
|
||||||
self.assert_auth_cleaned()
|
|
||||||
|
|
||||||
def test_health_failure_is_not_reported_as_success(self):
|
|
||||||
result = self.run_script(TEST_FAIL="up")
|
|
||||||
self.assertEqual(result.returncode, 23)
|
|
||||||
self.assertNotIn("Production is healthy", result.stdout)
|
|
||||||
self.assertIn("unlock", [c["op"] for c in self.calls()])
|
|
||||||
self.assert_auth_cleaned()
|
|
||||||
|
|
||||||
def test_missing_runtime_configuration_fails_before_docker(self):
|
|
||||||
for key in RUNTIME_KEYS:
|
|
||||||
with self.subTest(key=key):
|
|
||||||
result = self.run_script(**{key: ""})
|
|
||||||
self.assertNotEqual(result.returncode, 0)
|
|
||||||
self.assertIn(key, result.stderr)
|
|
||||||
self.assertEqual(self.calls(), [])
|
|
||||||
|
|
||||||
def test_ssh_transmits_literal_secrets_and_only_deployment_files(self):
|
|
||||||
marker = self.directory / "MUST_NOT_EXIST"
|
|
||||||
secret = f"literal $value 'double\" slash\\; $(touch {marker})\nsecond line"
|
|
||||||
result = self.run_script("deploy-remote.sh", ADMIN_PASSWORD=secret, WQ_PASSWORD=secret, REGISTRY_PASSWORD=secret)
|
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
|
||||||
self.assertFalse(marker.exists())
|
|
||||||
self.assertNotIn(secret, result.stdout + result.stderr)
|
|
||||||
login = next(c for c in self.calls() if c["op"] == "login")
|
|
||||||
self.assertEqual(login["password"], secret)
|
|
||||||
preflight = next(c for c in self.calls() if c["op"] == "preflight")
|
|
||||||
self.assertEqual(preflight["env"]["ADMIN_PASSWORD"], secret)
|
|
||||||
self.assertEqual(preflight["env"]["WQ_PASSWORD"], secret)
|
|
||||||
for call in self.calls():
|
|
||||||
if call["op"] in {"preflight", "migrate", "up"}:
|
|
||||||
self.assertEqual(call["env"]["DATABASE_URL"], self.env["DATABASE_URL"])
|
|
||||||
ssh = json.loads(self.ssh_log.read_text())
|
|
||||||
self.assertIn("StrictHostKeyChecking=yes", ssh["args"])
|
|
||||||
self.assertIn("BatchMode=yes", ssh["args"])
|
|
||||||
self.assertEqual(ssh["args"][ssh["args"].index("-p") + 1], "2222")
|
|
||||||
self.assertEqual(ssh["key"], self.env["PROD_SSH_KEY"] + "\n")
|
|
||||||
self.assertEqual(ssh["key_mode"], 0o600)
|
|
||||||
self.assertNotIn(secret, " ".join(ssh["args"]))
|
|
||||||
self.assertFalse(Path(ssh["key_path"]).exists())
|
|
||||||
releases = list((Path(self.env["DEPLOY_PATH"]) / "releases").iterdir())
|
|
||||||
self.assertEqual(len(releases), 1)
|
|
||||||
files = {str(p.relative_to(releases[0])) for p in releases[0].rglob("*") if p.is_file()}
|
|
||||||
self.assertEqual(files, {"compose.production.yaml", "scripts/deploy-production.sh"})
|
|
||||||
self.assert_auth_cleaned()
|
|
||||||
|
|
||||||
def test_ssh_and_remote_failures_propagate_and_clean_key(self):
|
|
||||||
for stage in ("ssh", "pull", "migrate", "up"):
|
|
||||||
with self.subTest(stage=stage):
|
|
||||||
result = self.run_script("deploy-remote.sh", TEST_FAIL=stage)
|
|
||||||
self.assertEqual(result.returncode, 255 if stage == "ssh" else 23, result.stderr)
|
|
||||||
self.assertNotIn("Production is healthy", result.stdout)
|
|
||||||
ssh = json.loads(self.ssh_log.read_text())
|
|
||||||
self.assertFalse(Path(ssh["key_path"]).exists())
|
|
||||||
self.assert_auth_cleaned()
|
|
||||||
|
|
||||||
def test_missing_ssh_configuration_fails_before_connecting(self):
|
|
||||||
for key in ("PROD_HOST", "PROD_USER", "PROD_SSH_KEY", "PROD_KNOWN_HOSTS", "DEPLOY_PATH"):
|
|
||||||
with self.subTest(key=key):
|
|
||||||
result = self.run_script("deploy-remote.sh", **{key: ""})
|
|
||||||
self.assertNotEqual(result.returncode, 0)
|
|
||||||
self.assertFalse(self.ssh_log.exists())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
Reference in New Issue
Block a user