#!/usr/bin/env node /** * Phoenix Deploy API — Gitea webhook receiver and deploy endpoint stub * * Endpoints: * POST /webhook/gitea — Receives Gitea push/tag/PR webhooks * POST /api/deploy — Deploy request (repo, branch, target) * * Env: PORT, GITEA_URL, GITEA_TOKEN, PHOENIX_DEPLOY_SECRET */ import crypto from 'crypto'; import express from 'express'; const PORT = parseInt(process.env.PORT || '4001', 10); const GITEA_URL = (process.env.GITEA_URL || 'https://gitea.d-bis.org').replace(/\/$/, ''); const GITEA_TOKEN = process.env.GITEA_TOKEN || ''; const WEBHOOK_SECRET = process.env.PHOENIX_DEPLOY_SECRET || ''; const app = express(); // Keep raw body for webhook HMAC verification (Gitea uses HMAC-SHA256 of body) app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })); /** * Update Gitea commit status (pending/success/failure) */ async function setGiteaCommitStatus(owner, repo, sha, state, description, targetUrl = '') { if (!GITEA_TOKEN) return; const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/statuses/${sha}`; const body = { state, description, context: 'phoenix-deploy', target_url: targetUrl || undefined }; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `token ${GITEA_TOKEN}`, }, body: JSON.stringify(body), }); if (!res.ok) { console.error(`Gitea status failed: ${res.status} ${await res.text()}`); } } /** * POST /webhook/gitea — Gitea webhook receiver * Supports: push, tag, pull_request */ app.post('/webhook/gitea', async (req, res) => { const payload = req.body; if (!payload) { return res.status(400).json({ error: 'No payload' }); } // Validate X-Gitea-Signature or X-Gogs-Signature (HMAC-SHA256 of raw body, hex) if (WEBHOOK_SECRET) { const sig = req.headers['x-gitea-signature'] || req.headers['x-gogs-signature']; if (!sig) { return res.status(401).json({ error: 'Missing webhook signature' }); } const raw = req.rawBody || Buffer.from(JSON.stringify(payload)); const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(raw).digest('hex'); const sigNormalized = String(sig).replace(/^sha256=/, '').trim(); const expectedBuf = Buffer.from(expected, 'hex'); const sigBuf = Buffer.from(sigNormalized, 'hex'); if (expectedBuf.length !== sigBuf.length || !crypto.timingSafeEqual(expectedBuf, sigBuf)) { return res.status(401).json({ error: 'Invalid webhook signature' }); } } const action = payload.action || (payload.ref ? 'push' : null); const ref = payload.ref || ''; const repo = payload.repository; if (!repo) { return res.status(400).json({ error: 'No repository in payload' }); } const ownerObj = repo.owner || {}; const fullName = repo.full_name || `${ownerObj.username || ownerObj.login || 'unknown'}/${repo.name || 'repo'}`; const [owner, repoName] = fullName.split('/'); const branch = ref.replace('refs/heads/', '').replace('refs/tags/', ''); const pr = payload.pull_request || {}; const head = pr.head || {}; const sha = payload.after || (payload.sender && payload.sender.sha) || head.sha || ''; console.log(`[webhook] ${action || 'push'} ${fullName} ${branch} ${sha}`); if (action === 'push' || (action === 'synchronize' && payload.pull_request)) { if (branch === 'main' || branch === 'master' || ref.startsWith('refs/tags/')) { if (sha && GITEA_TOKEN) { await setGiteaCommitStatus(owner, repoName, sha, 'pending', 'Phoenix deployment triggered'); } // Stub: enqueue deploy; actual implementation would call Proxmox/deploy logic console.log(`[deploy-stub] Would deploy ${fullName} branch=${branch} sha=${sha}`); // Stub: when full deploy runs, call setGiteaCommitStatus(owner, repoName, sha, 'success'|'failure', ...) } } res.status(200).json({ received: true, repo: fullName, branch, sha }); }); /** * POST /api/deploy — Deploy endpoint * Body: { repo, branch?, target?, sha? } */ app.post('/api/deploy', async (req, res) => { const auth = req.headers.authorization; if (WEBHOOK_SECRET && auth !== `Bearer ${WEBHOOK_SECRET}`) { return res.status(401).json({ error: 'Unauthorized' }); } const { repo, branch = 'main', target, sha } = req.body; if (!repo) { return res.status(400).json({ error: 'repo required' }); } const [owner, repoName] = repo.includes('/') ? repo.split('/') : ['d-bis', repo]; const commitSha = sha || ''; if (commitSha && GITEA_TOKEN) { await setGiteaCommitStatus(owner, repoName, commitSha, 'pending', 'Phoenix deployment in progress'); } console.log(`[deploy] ${repo} branch=${branch} target=${target || 'default'} sha=${commitSha}`); // Stub: no real deploy yet — report success so Gitea shows green; replace with real deploy + setGiteaCommitStatus on completion const deploySuccess = true; if (commitSha && GITEA_TOKEN) { await setGiteaCommitStatus( owner, repoName, commitSha, deploySuccess ? 'success' : 'failure', deploySuccess ? 'Deploy accepted (stub)' : 'Deploy failed (stub)' ); } res.status(202).json({ status: 'accepted', repo, branch, target: target || 'default', message: 'Deploy request queued (stub). Implement full deploy logic in Sankofa Phoenix API.', }); }); /** * GET /health — Health check */ app.get('/health', (req, res) => { res.json({ status: 'ok', service: 'phoenix-deploy-api' }); }); app.listen(PORT, () => { console.log(`Phoenix Deploy API listening on port ${PORT}`); if (!GITEA_TOKEN) console.warn('GITEA_TOKEN not set — commit status updates disabled'); });