建立推送机制 webhook

This commit is contained in:
2026-07-06 16:25:18 +08:00
parent f46904b47c
commit 8fc142b7c8
8 changed files with 297 additions and 1 deletions
+15
View File
@@ -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
+60
View File
@@ -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)"
+1 -1
View File
@@ -6,4 +6,4 @@ DEPLOY_PORT=22
APP_ROOT=/opt/dukang-haoke
GIT_REMOTE=origin
GIT_BRANCH=main
GIT_BRANCH=dev
+11
View File
@@ -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',
},
},
],
};
+15
View File
@@ -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;
}
+1
View File
@@ -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
+74
View File
@@ -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"
+120
View File
@@ -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}`);
});