diff --git a/deploy/auto-release.env.example b/deploy/auto-release.env.example new file mode 100644 index 0000000..2286e63 --- /dev/null +++ b/deploy/auto-release.env.example @@ -0,0 +1,15 @@ +# 复制为 auto-release.env(勿提交 Git) +# 服务器路径: /opt/dukang-haoke/deploy/auto-release.env + +APP_ROOT=/opt/dukang-haoke +GIT_REMOTE=origin +GIT_BRANCH=dev +DEPLOY_GIT_REF=refs/heads/dev + +# Webhook 密钥(CodeUp 配置「Secret Token」时使用同一值) +DEPLOY_WEBHOOK_SECRET=change-me-to-a-long-random-string + +DEPLOY_WEBHOOK_HOST=127.0.0.1 +DEPLOY_WEBHOOK_PORT=8095 +DEPLOY_LOG_FILE=/var/log/dukang/deploy.log +DEPLOY_LOCK_FILE=/var/run/dukang-deploy.lock diff --git a/deploy/auto-release.sh b/deploy/auto-release.sh new file mode 100644 index 0000000..7510abe --- /dev/null +++ b/deploy/auto-release.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# 自动发版:git pull + remote-release(由 webhook 或手动触发) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ENV_FILE="$SCRIPT_DIR/auto-release.env" + +APP_ROOT="${APP_ROOT:-/opt/dukang-haoke}" +GIT_REMOTE="${GIT_REMOTE:-origin}" +GIT_BRANCH="${GIT_BRANCH:-dev}" +LOCK_FILE="${DEPLOY_LOCK_FILE:-/var/run/dukang-deploy.lock}" +LOG_FILE="${DEPLOY_LOG_FILE:-/var/log/dukang/deploy.log}" + +if [[ -f "$ENV_FILE" ]]; then + # shellcheck disable=SC1090 + source "$ENV_FILE" +fi + +mkdir -p "$(dirname "$LOG_FILE")" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" +} + +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + log "SKIP: another deploy is in progress" + exit 0 +fi + +log "START auto-release (branch=$GIT_BRANCH)" + +cd "$APP_ROOT" + +if [[ ! -d .git ]]; then + log "ERROR: $APP_ROOT is not a git repository" + exit 1 +fi + +git fetch "$GIT_REMOTE" +git checkout "$GIT_BRANCH" + +LOCAL_SHA="$(git rev-parse HEAD)" +REMOTE_SHA="$(git rev-parse "$GIT_REMOTE/$GIT_BRANCH")" + +if [[ "$LOCAL_SHA" == "$REMOTE_SHA" ]]; then + log "SKIP: already up to date ($LOCAL_SHA)" + exit 0 +fi + +log "PULL $LOCAL_SHA -> $REMOTE_SHA" +git pull --ff-only "$GIT_REMOTE" "$GIT_BRANCH" + +# 修复 Windows 换行 +find "$APP_ROOT/deploy" -maxdepth 1 -name '*.sh' -exec sed -i 's/\r$//' {} + 2>/dev/null || true + +log "RUN remote-release.sh" +bash "$APP_ROOT/deploy/remote-release.sh" + +log "DONE auto-release ($REMOTE_SHA)" diff --git a/deploy/deploy.env.example b/deploy/deploy.env.example index cc0c8fe..5c3b985 100644 --- a/deploy/deploy.env.example +++ b/deploy/deploy.env.example @@ -6,4 +6,4 @@ DEPLOY_PORT=22 APP_ROOT=/opt/dukang-haoke GIT_REMOTE=origin -GIT_BRANCH=main +GIT_BRANCH=dev diff --git a/deploy/ecosystem.config.cjs b/deploy/ecosystem.config.cjs index 22f5798..e9502cf 100644 --- a/deploy/ecosystem.config.cjs +++ b/deploy/ecosystem.config.cjs @@ -50,5 +50,16 @@ module.exports = { instances: 1, exec_mode: 'fork', }, + { + name: 'dukang-deploy-webhook', + cwd: `${APP_ROOT}/deploy`, + script: 'webhook-server.mjs', + interpreter: 'node', + instances: 1, + exec_mode: 'fork', + env: { + NODE_ENV: 'production', + }, + }, ], }; diff --git a/deploy/nginx-deploy-webhook.conf b/deploy/nginx-deploy-webhook.conf new file mode 100644 index 0000000..82b8454 --- /dev/null +++ b/deploy/nginx-deploy-webhook.conf @@ -0,0 +1,15 @@ +# 杜康好客 — CodeUp Webhook 反代(挂到 dkapi.runxian.top 443/80 server 块内) +# setup-webhook.sh 会自动 include 此文件 + +location = /hooks/deploy { + proxy_pass http://127.0.0.1:8095/deploy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Gitlab-Token $http_x_gitlab_token; + proxy_set_header X-Codeup-Token $http_x_codeup_token; + proxy_set_header X-Deploy-Token $http_x_deploy_token; + client_max_body_size 1m; +} diff --git a/deploy/remote-release.sh b/deploy/remote-release.sh index b0f24ff..f11de2a 100644 --- a/deploy/remote-release.sh +++ b/deploy/remote-release.sh @@ -42,6 +42,7 @@ corepack prepare pnpm@11.2.2 --activate 2>/dev/null || true echo "==> 2. 安装依赖" export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=2048}" +pnpm approve-builds --all 2>/dev/null || true pnpm install --frozen-lockfile 2>/dev/null || pnpm install if [[ "$SKIP_BUILD" == false ]]; then diff --git a/deploy/setup-webhook.sh b/deploy/setup-webhook.sh new file mode 100644 index 0000000..f62065b --- /dev/null +++ b/deploy/setup-webhook.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# 在服务器上执行:安装 Webhook 自动发版 +set -euo pipefail + +APP_ROOT="${APP_ROOT:-/opt/dukang-haoke}" +DEPLOY_DIR="$APP_ROOT/deploy" +ENV_FILE="$DEPLOY_DIR/auto-release.env" +LOG_DIR="/var/log/dukang" + +echo "==> 1. 准备目录" +mkdir -p "$LOG_DIR" /var/run +chmod +x "$DEPLOY_DIR/auto-release.sh" "$DEPLOY_DIR/setup-webhook.sh" 2>/dev/null || true +sed -i 's/\r$//' "$DEPLOY_DIR"/*.sh "$DEPLOY_DIR"/*.mjs 2>/dev/null || true + +echo "==> 2. 生成 webhook 密钥(若不存在)" +if [[ ! -f "$ENV_FILE" ]]; then + SECRET="$(openssl rand -hex 24)" + cp "$DEPLOY_DIR/auto-release.env.example" "$ENV_FILE" + sed -i "s/change-me-to-a-long-random-string/$SECRET/" "$ENV_FILE" + chmod 600 "$ENV_FILE" + echo " 已创建 $ENV_FILE" +else + echo " 保留已有 $ENV_FILE" +fi + +echo "==> 3. Nginx webhook 路由" +HOOK_CONF="$DEPLOY_DIR/nginx-deploy-webhook.conf" +# 勿放在 /etc/nginx/conf.d/(会被 http 上下文直接加载) +rm -f /etc/nginx/conf.d/dukang-deploy-webhook.conf + +MARKER="include $HOOK_CONF;" +for conf in /etc/nginx/conf.d/dukang-runxian.conf /etc/nginx/conf.d/dukang-runxian-ssl.conf; do + if [[ -f "$conf" ]] && grep -q 'server_name dkapi.runxian.top' "$conf"; then + # 清理旧错误 include + sed -i '\|include /etc/nginx/conf.d/dukang-deploy-webhook.conf;|d' "$conf" + if ! grep -qF "$MARKER" "$conf"; then + sed -i "/server_name dkapi.runxian.top;/a\\ $MARKER" "$conf" + echo " 已 patch $conf" + fi + fi +done + +nginx -t +systemctl reload nginx + +echo "==> 4. 启动 PM2 webhook" +cd "$APP_ROOT" +if pm2 describe dukang-deploy-webhook &>/dev/null; then + pm2 restart dukang-deploy-webhook +else + pm2 start "$DEPLOY_DIR/ecosystem.config.cjs" --only dukang-deploy-webhook +fi +pm2 save + +echo "==> 5. 健康检查" +sleep 1 +curl -sf http://127.0.0.1:8095/health | head -c 200 +echo + +SECRET="$(grep DEPLOY_WEBHOOK_SECRET "$ENV_FILE" | cut -d= -f2- | tr -d '\"')" +echo "" +echo "==========================================" +echo " Webhook 已就绪" +echo " URL: https://dkapi.runxian.top/hooks/deploy" +echo " Secret: $SECRET" +echo " Branch: dev (refs/heads/dev)" +echo " Log: $LOG_DIR/deploy.log" +echo "==========================================" +echo "" +echo "CodeUp 配置:仓库 → 设置 → Webhooks → 添加" +echo " URL: https://dkapi.runxian.top/hooks/deploy" +echo " Secret Token: (与上方 Secret 相同)" +echo " 触发事件: Push events" +echo " 分支过滤: dev" diff --git a/deploy/webhook-server.mjs b/deploy/webhook-server.mjs new file mode 100644 index 0000000..93dddba --- /dev/null +++ b/deploy/webhook-server.mjs @@ -0,0 +1,120 @@ +/** + * CodeUp / GitLab 兼容的部署 Webhook 接收器 + * 监听 127.0.0.1:8095,由 Nginx 反代 /hooks/deploy + */ +import http from 'http'; +import { spawn } from 'child_process'; +import { readFileSync, existsSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function loadEnv() { + const envPath = join(__dirname, 'auto-release.env'); + if (!existsSync(envPath)) return; + for (const line of readFileSync(envPath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + let val = trimmed.slice(eq + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + if (!(key in process.env)) process.env[key] = val; + } +} + +loadEnv(); + +const PORT = Number(process.env.DEPLOY_WEBHOOK_PORT || 8095); +const HOST = process.env.DEPLOY_WEBHOOK_HOST || '127.0.0.1'; +const SECRET = process.env.DEPLOY_WEBHOOK_SECRET || ''; +const ALLOWED_REF = process.env.DEPLOY_GIT_REF || 'refs/heads/dev'; +const APP_ROOT = process.env.APP_ROOT || '/opt/dukang-haoke'; +const RELEASE_SCRIPT = join(APP_ROOT, 'deploy', 'auto-release.sh'); + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +function getToken(req) { + return ( + req.headers['x-gitlab-token'] || + req.headers['x-codeup-token'] || + req.headers['x-deploy-token'] || + '' + ); +} + +function json(res, status, data) { + const body = JSON.stringify(data); + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(body); +} + +function triggerRelease(trigger) { + const child = spawn('bash', [RELEASE_SCRIPT], { + detached: true, + stdio: 'ignore', + env: { ...process.env, DEPLOY_TRIGGER: trigger }, + }); + child.unref(); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + + if (req.method === 'GET' && url.pathname === '/health') { + return json(res, 200, { ok: true, service: 'dukang-deploy-webhook' }); + } + + if (req.method !== 'POST' || url.pathname !== '/deploy') { + return json(res, 404, { ok: false, message: 'not found' }); + } + + if (!SECRET) { + return json(res, 503, { ok: false, message: 'webhook secret not configured' }); + } + + const token = getToken(req); + if (token !== SECRET) { + return json(res, 403, { ok: false, message: 'invalid token' }); + } + + let payload = {}; + try { + const raw = await readBody(req); + if (raw) payload = JSON.parse(raw); + } catch { + return json(res, 400, { ok: false, message: 'invalid json body' }); + } + + const ref = payload.ref || payload.object_attributes?.ref || ''; + if (ref && ref !== ALLOWED_REF) { + return json(res, 200, { + ok: true, + skipped: true, + message: `ignored ref: ${ref} (allowed: ${ALLOWED_REF})`, + }); + } + + triggerRelease(ref || 'manual'); + return json(res, 202, { + ok: true, + accepted: true, + message: 'deploy started', + ref: ref || ALLOWED_REF, + }); +}); + +server.listen(PORT, HOST, () => { + console.log(`dukang deploy webhook listening on http://${HOST}:${PORT}`); +});