ffd9180315
Co-authored-by: Cursor <cursoragent@cursor.com>
137 lines
4.2 KiB
JavaScript
137 lines
4.2 KiB
JavaScript
/**
|
|
* 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.replace(/\r$/, '').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 || '').replace(/\r$/, '').trim();
|
|
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');
|
|
const DEBOUNCE_MS = Number(process.env.DEPLOY_WEBHOOK_DEBOUNCE_MS || 15000);
|
|
|
|
let lastTriggerAt = 0;
|
|
let lastTriggerRef = '';
|
|
|
|
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 now = Date.now();
|
|
if (now - lastTriggerAt < DEBOUNCE_MS && lastTriggerRef === trigger) {
|
|
console.log(`[webhook] debounced duplicate trigger ref=${trigger}`);
|
|
return false;
|
|
}
|
|
lastTriggerAt = now;
|
|
lastTriggerRef = trigger;
|
|
console.log(`[webhook] trigger deploy ref=${trigger}`);
|
|
const child = spawn('bash', [RELEASE_SCRIPT], {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
env: { ...process.env, DEPLOY_TRIGGER: trigger },
|
|
});
|
|
child.unref();
|
|
return true;
|
|
}
|
|
|
|
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) {
|
|
console.log(`[webhook] rejected invalid token from ${req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown'}`);
|
|
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) {
|
|
console.log(`[webhook] ignored ref=${ref} (allowed=${ALLOWED_REF})`);
|
|
return json(res, 200, {
|
|
ok: true,
|
|
skipped: true,
|
|
message: `ignored ref: ${ref} (allowed: ${ALLOWED_REF})`,
|
|
});
|
|
}
|
|
|
|
const started = triggerRelease(ref || 'manual');
|
|
return json(res, 202, {
|
|
ok: true,
|
|
accepted: true,
|
|
started,
|
|
message: started ? 'deploy started' : 'deploy debounced (duplicate webhook)',
|
|
ref: ref || ALLOWED_REF,
|
|
});
|
|
});
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
console.log(`dukang deploy webhook listening on http://${HOST}:${PORT}`);
|
|
});
|