-
Notifications
You must be signed in to change notification settings - Fork 0
343 lines (312 loc) · 17.4 KB
/
Copy pathdeploy.yml
File metadata and controls
343 lines (312 loc) · 17.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
name: Deploy
# 触发:push 到 main / dev,或 Actions 页面手动 dispatch
# 模式:runner 编译 → 生成 infra/.env from secrets → scp 整包 → 服务器 install prod + migrate + 重启
# 不构建镜像、不进 Docker;服务器只跑 native node 进程(pm2 / systemd 二选一,用户维护)
on:
push:
branches: [main, dev]
workflow_dispatch:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
env:
NODE_VERSION: '22'
PNPM_VERSION: '9.12.0'
jobs:
deploy:
runs-on: ubuntu-latest
# 用同名 Environment 给 dev / main 配不同 secrets(可选)
environment: ${{ github.ref_name }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate prisma client (typecheck during build needs it)
run: pnpm prisma:generate
- name: Build all apps + packages
run: pnpm build
# ─── 把所有 secrets 生成为 infra/.env,与编译产物一起 scp 过去 ───────────
# 服务器**不需要**自己维护 .env,所有敏感配置由 GH Secrets 统一管理
# 非敏感字段直接 hardcode;新增字段时在这里加一行 + 在 GH 加 secret 即可
- name: Create infra/.env from secrets
run: |
mkdir -p infra
cat > infra/.env <<EOF
# === Auto-generated by GitHub Actions on $(date -u +%FT%TZ) ===
# === Do NOT edit on server; rerun deploy to update ===
NODE_ENV=production
SERVICE_VERSION=${{ github.sha }}
LOG_LEVEL=info
# ─── Database (整条 DATABASE_URL 一个 secret 完事,Prisma 标准) ─────
DATABASE_URL=${{ secrets.DATABASE_URL }}
# ─── API ─────────────────────────────────────────────────────────
PORT=${{ secrets.API_PORT || '8080' }}
HOST=0.0.0.0
JWT_SECRET=${{ secrets.JWT_SECRET }}
JWT_EXPIRES_IN_SECONDS=86400
CORS_ORIGINS=${{ secrets.CORS_ORIGINS }}
INFRA_BACKEND=memory
CACHE_DEFAULT_TTL_SECONDS=60
MEMORY_CACHE_MAX_ITEMS=10000
EVENTS_OUTBOX_POLL_INTERVAL_MS=1000
EVENTS_OUTBOX_BATCH_SIZE=500
RATE_LIMIT_GLOBAL_RPS=20
# ─── Worker: AI providers ────────────────────────────────────────
AI_DISABLE=${{ secrets.AI_DISABLE || 'false' }}
AI_PROVIDER=anthropic
ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_BASE_URL=${{ secrets.ANTHROPIC_BASE_URL }}
DEEPSEEK_API_KEY=${{ secrets.DEEPSEEK_API_KEY }}
DEEPSEEK_BASE_URL=${{ secrets.DEEPSEEK_BASE_URL }}
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL=${{ secrets.OPENAI_BASE_URL }}
AI_MODEL_ANALYZER=${{ secrets.AI_MODEL_ANALYZER || 'claude-sonnet-4-6' }}
AI_MODEL_SCORER=${{ secrets.AI_MODEL_SCORER || 'claude-opus-4-7' }}
AI_MODEL_SUGGESTER=${{ secrets.AI_MODEL_SUGGESTER || 'claude-opus-4-7' }}
# ─── Worker: quote / market data sources ─────────────────────────
# Infoway (docs.infoway.io) — 第三方实时行情聚合,A 股 + 港股 + 美股
INFOWAY_API_KEY=${{ secrets.INFOWAY_API_KEY }}
INFOWAY_BASE_URL=${{ secrets.INFOWAY_BASE_URL || 'https://data.infoway.io' }}
# 动态代理(青果 QG 等"调 API 取 IP"模式)。空 = 关闭直连;
# 配置后 EM/腾讯/新浪 outbound 走代理,deadline 主动 + 失败被动双策略换 IP
PROXY_FETCH_URL=${{ secrets.PROXY_FETCH_URL }}
# 串行采集每股间隔(quote / intraday flow / limit snapshot 共用)
# 反爬端看到均匀流而非 burst;16 股 × 1500ms = 24s 跑完一轮
COLLECT_PER_STOCK_GAP_MS=${{ secrets.COLLECT_PER_STOCK_GAP_MS || '1500' }}
# ─── Worker: scraping ────────────────────────────────────────────
SCRAPE_INTERVAL_CRON=*/10 * * * *
SCRAPE_INBOX_POLL_INTERVAL_MS=3000
SCRAPE_CONCURRENCY=4
SCRAPE_REQUEST_TIMEOUT_MS=30000
SCRAPE_USER_AGENT=SignalForge/1.0 (+https://signalforge.local; research; contact=admin@signalforge.local)
AI_CONCURRENCY=2
AI_REQUEST_TIMEOUT_MS=60000
WORKER_HEALTH_PORT=9090
# ─── Retention ───────────────────────────────────────────────────
EVENTS_OUTBOX_RETENTION_DAYS=7
SCRAPE_JOBS_RETENTION_DAYS=90
AI_JOBS_RETENTION_DAYS=180
# ─── BYOK 主密钥(AES-256-GCM 加密 user_ai_config.api_key_enc)──
# ⚠️ 必须在 GH Secrets 设 AI_CONFIG_MASTER_KEY (openssl rand -base64 32)
# 主密钥变了所有已存 user key 解不出来,需要 lazy re-encrypt 流程
AI_CONFIG_MASTER_KEY=${{ secrets.AI_CONFIG_MASTER_KEY }}
EOF
# ─── 在 runner 上生成 deploy/ 脚本(不在 SSH script 里 heredoc) ───────
# appleboy/ssh-action 的 script_stop:true 会在每行间注入 DRONE_SSH 检查,
# 把 server 侧 heredoc 内容污染(曾导致 ecosystem.config.cjs 第 1 行变成
# shell 检查语句而非 JS 代码)。改成 runner 生成 → scp 过去 → 不再 heredoc
- name: Generate deploy/ scripts on runner
run: |
mkdir -p deploy
# ecosystem 总是覆写——这是 workflow 所有权的 auto-generated 配置
cat > deploy/ecosystem.config.cjs <<'ECOEOF'
// Auto-generated by .github/workflows/deploy.yml; 不要手改,下次部署会覆写
// pm2 ecosystem,从 infra/.env 注入环境变量
const path = require('path');
const fs = require('fs');
// 简易 .env parser(不引外部依赖;pm2 全局装通常不带 dotenv 模块)
function parseEnv(file) {
const out = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const s = line.trim();
if (!s || s.startsWith('#')) continue;
const i = s.indexOf('=');
if (i < 0) continue;
const k = s.slice(0, i).trim();
let v = s.slice(i + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
out[k] = v;
}
return out;
}
const env = parseEnv(path.resolve(__dirname, '../infra/.env'));
module.exports = {
apps: [
{
name: 'sf-api',
cwd: path.resolve(__dirname, '..'),
script: 'apps/api/dist/server.js',
env,
max_memory_restart: '512M',
time: true,
},
{
name: 'sf-worker',
cwd: path.resolve(__dirname, '..'),
script: 'apps/worker/dist/main.js',
env,
max_memory_restart: '512M',
time: true,
},
],
};
ECOEOF
# restart.sh 用 .template 形式,服务器首次部署 cp 一份作为真正的
# restart.sh;之后用户可改 restart.sh 切到 systemd 等,不会被覆写
cat > deploy/restart.sh.template <<'RSEOF'
#!/usr/bin/env bash
# 默认重启脚本:pm2 + auto-generated ecosystem
# 想换 systemd / 其它进程管理:直接编辑此文件(同目录下),workflow 不会覆写
set -euo pipefail
cd "$(dirname "$0")/.."
pm2 startOrReload deploy/ecosystem.config.cjs --update-env
pm2 save
RSEOF
chmod +x deploy/restart.sh.template
# migrate.sh: 带"自愈"的 prisma migrate deploy 包装
#
# 历史故障 case 1(_prisma_migrations 卡 failed):
# 迁移 12 在生产挂在 failed 状态 → 后续 deploy P3009 不动
# 历史故障 case 2(_prisma_migrations 已标 applied 但 DDL 没跑):
# 人手动 resolve --applied 后,实际 schema 没变 → worker P2000
#
# 自愈策略(每个 SELF_HEAL_MIGRATIONS 条目执行一遍 idempotent DDL):
# 1. 用 `prisma db execute` 跑一次 DDL(idempotent: MODIFY 到已有类型是 no-op)
# — 用 db execute 不依赖 generated client,无路径问题
# 2. 跑 resolve --applied 兜住 _prisma_migrations 残留的 failed 行
# — 已 applied 时 prisma 返回 P3008,用 || true 吞掉
# 3. 最后正常 prisma migrate deploy,新迁移按序应用
#
# 加新自愈条目(未来再遇到迁移挂掉):在 SELF_HEAL_MIGRATIONS 加一行
# (migration 名 + idempotent SQL),不动 workflow 主体
cat > deploy/migrate.sh <<'MIGEOF'
#!/usr/bin/env bash
# Auto-generated by .github/workflows/deploy.yml; 不要手改,下次部署会覆写
set -uo pipefail
cd "$(dirname "$0")/../apps/api"
# 自愈条目:name|idempotent SQL
# 加新条目时:每行一个,SQL 单条(MySQL prisma db execute 不接受 ; 分隔的多条)
SELF_HEAL_MIGRATIONS=(
"12_extend_prompt_version|ALTER TABLE trading_suggestions MODIFY COLUMN prompt_version VARCHAR(64) NOT NULL"
)
for entry in "${SELF_HEAL_MIGRATIONS[@]}"; do
name="${entry%%|*}"
sql="${entry#*|}"
echo "[self-heal] $name → running idempotent DDL via PrismaClient \$executeRawUnsafe"
echo " SQL: $sql"
# 用 generated PrismaClient(走 ./prisma/.client 自定义路径)+ \$executeRawUnsafe
# 这与 worker 是同一条连接路径(同样 require('./prisma/.client'))
# 上次用 `prisma db execute` 抛 P1014 但 worker 同时在写表,说明两端连接行为不同
# SQL 通过 argv 传入,避免 heredoc 引号转义问题
if pnpm dotenv -e ../../infra/.env -- node -e "
const { PrismaClient } = require('./prisma/.client');
const p = new PrismaClient();
const sql = process.argv[1];
p.\$executeRawUnsafe(sql)
.then(n => { console.log(' rows affected:', n); return p.\$disconnect(); })
.then(() => process.exit(0))
.catch(e => { console.error(' DDL error:', e.message); p.\$disconnect().finally(() => process.exit(1)); });
" -- "$sql"; then
echo " DDL applied"
else
rc=$?
echo " DDL exit $rc (non-fatal, continuing)"
fi
echo "[self-heal] $name → ensuring _prisma_migrations marked applied"
pnpm dotenv -e ../../infra/.env -- prisma migrate resolve --applied "$name" 2>&1 \
| sed 's/^/ /' \
|| echo " (resolve returned non-zero — typically P3008 already-applied, ok)"
done
# 诊断:打印连接/表存在性/列宽,定位 DDL 出错的环境
# 注意:实际表名是 trading_suggestions(复数),Prisma model 是 TradingSuggestion
# 但 @@map("trading_suggestions") → raw SQL 必须用复数
echo ""
echo "[diagnose] dumping connection + schema info"
# 各项独立 try/catch,任何一项炸不影响后面
# 不能用 'current_user' 作 alias(MySQL 保留字)
# 用直接 SELECT FROM trading_suggestions 是最稳的表存在性测试:
# 有表 → 返回 count
# 没表 → MySQL 1146,异常打印明确
# 比 information_schema 准(不受权限或视图过滤影响)
pnpm dotenv -e ../../infra/.env -- node -e "
const { PrismaClient } = require('./prisma/.client');
const p = new PrismaClient();
(async () => {
const url = process.env.DATABASE_URL || '';
const masked = url.replace(/(:\/\/[^:]+:)[^@]+(@)/, '\$1***\$2');
console.log(' DATABASE_URL (masked):', masked);
try {
const conn = await p.\$queryRawUnsafe('SELECT DATABASE() AS db_name, USER() AS db_user, @@version AS mysql_ver');
console.log(' connection:', JSON.stringify(conn));
} catch (e) { console.log(' connection query failed:', e.message); }
try {
const cnt = await p.\$queryRawUnsafe('SELECT COUNT(*) AS n FROM trading_suggestions');
console.log(' trading_suggestions direct count:', JSON.stringify(cnt));
} catch (e) {
console.log(' trading_suggestions DIRECT QUERY FAILED:', e.message);
}
try {
const col = await p.\$queryRawUnsafe(
\"SELECT TABLE_SCHEMA, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_NAME = 'trading_suggestions' AND COLUMN_NAME = 'prompt_version'\"
);
console.log(' prompt_version info_schema:', JSON.stringify(col));
} catch (e) { console.log(' info_schema lookup failed:', e.message); }
try {
const tcnt = await p.\$queryRawUnsafe('SELECT COUNT(*) AS n FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()');
console.log(' tables in current DB:', JSON.stringify(tcnt));
} catch (e) { console.log(' table count failed:', e.message); }
await p.\$disconnect();
})().catch(e => { console.log(' outer failure:', e.message); process.exit(0); });
" || true
echo ""
echo "[migrate] running prisma migrate deploy"
set -e
pnpm dotenv -e ../../infra/.env -- prisma migrate deploy
MIGEOF
chmod +x deploy/migrate.sh
# ─── 把编译后的整个工作区送上去(含 dist、prisma 文件、.env、deploy/) ───
# 注意:node_modules 不传(服务器自行 pnpm install --prod)
# .git/.github/.vscode 不传(与运行时无关)
# 源码 src 也送过去:体积小,prisma generate / migrate 命令需要 schema
# deploy/ 也送:含 runner 生成的 ecosystem + restart.sh.template
- name: SCP build output + sources + .env + deploy scripts to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT || 22 }}
source: 'apps/,packages/,infra/,deploy/,package.json,pnpm-lock.yaml,pnpm-workspace.yaml,tsconfig.json,tsconfig.base.json,!**/node_modules/**,!**/__tests__/**,!**/*.test.ts,!**/*.spec.ts,!**/.turbo/**'
target: ${{ secrets.DEPLOY_PATH }}
strip_components: 0
overwrite: true
# ─── 服务器:装 prod 依赖 + 服务器侧 prisma generate + migrate + 重启 ──
# prisma engine 是平台相关二进制,必须在目标 OS 上重新 generate
# restart.sh 由你在服务器维护(pm2 / systemd 都行,DEPLOY.md 有样板)
- name: SSH and finalize
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT || 22 }}
script_stop: true
envs: DEPLOY_PATH
# ⚠️ 一律 single-line 命令——appleboy/ssh-action 在每个换行间注入
# exit-check,多行 heredoc 会被污染。文件已由 runner 生成 + scp 上来。
script: |
set -euo pipefail
cd "$DEPLOY_PATH"
pnpm install --frozen-lockfile
pnpm prisma:generate
bash ./deploy/migrate.sh
ln -sfn api/prisma apps/prisma
ln -sfn apps/api api
command -v pm2 >/dev/null 2>&1 || (command -v sudo >/dev/null 2>&1 && sudo npm install -g pm2 || npm install -g pm2)
[ -f deploy/restart.sh ] || cp deploy/restart.sh.template deploy/restart.sh
chmod +x deploy/restart.sh
bash ./deploy/restart.sh
echo ""
echo "[pm2-env] dumping running processes' actual DATABASE_URL (post-restart)"
pm2 jlist 2>/dev/null | node -e "let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{const apps=JSON.parse(d);for(const a of apps){const env=(a.pm2_env||{});const url=env.DATABASE_URL||'(unset)';const m=url.replace(/(:\/\/[^:]+:)[^@]+(@)/,'\$1***\$2');console.log(' ',a.name,'pid',a.pid,'restarts',(a.pm2_env||{}).restart_time,'DATABASE_URL:',m)}}catch(e){console.log(' pm2 env dump failed:',e.message)}});" || echo "(pm2 jlist failed)"
env:
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}