From 7609204a1307ae6f626d49805f2546847a1f580d Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 29 Apr 2026 16:12:22 +0800 Subject: [PATCH] feat: tambah deploy pipeline tool di MCP deploy-stg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool baru `deploy` menjalankan full pipeline: 1. Cek pending migrations → batalkan jika ada 2. Version bump package.json ke tag baru 3. Commit + push ke build/stg 4. Trigger publish.yml → polling hingga selesai 5. Trigger re-pull.yml → polling hingga selesai 6. Cek version di STG_URL vs local untuk konfirmasi Env baru: STG_URL (staging app URL), VERSION_PATH (default /api/system/version) Co-Authored-By: Claude Sonnet 4.6 --- .mcp.json | 8 +- scripts/mcp-deploy.ts | 212 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 200 insertions(+), 20 deletions(-) diff --git a/.mcp.json b/.mcp.json index 8a92383..f9e5464 100644 --- a/.mcp.json +++ b/.mcp.json @@ -5,9 +5,11 @@ "command": "bun", "args": ["scripts/mcp-deploy.ts"], "env": { - "GH_TOKEN": "", - "STACK_NAME": "", - "BASE_URL": "" + "GH_TOKEN": "${GH_TOKEN}", + "STACK_NAME": "${STACK_NAME}", + "BASE_URL": "${BASE_URL}", + "STG_URL": "${STG_URL}", + "VERSION_PATH": "/api/system/version" } } } diff --git a/scripts/mcp-deploy.ts b/scripts/mcp-deploy.ts index 8e4b866..09fb210 100644 --- a/scripts/mcp-deploy.ts +++ b/scripts/mcp-deploy.ts @@ -2,46 +2,224 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' -const GH_TOKEN = process.env.GH_TOKEN ?? '' -const STACK_NAME = process.env.STACK_NAME ?? '' -const BASE_URL = process.env.BASE_URL ?? '' // e.g. https://api.github.com/repos/org/repo +const GH_TOKEN = process.env.GH_TOKEN ?? '' +const STACK_NAME = process.env.STACK_NAME ?? '' +const BASE_URL = process.env.BASE_URL ?? '' // https://api.github.com/repos/owner/repo +const STG_URL = process.env.STG_URL ?? '' // https://monitoring-stg.example.com +const VERSION_PATH = process.env.VERSION_PATH ?? '/api/system/version' + +// ─── GitHub API helpers ──────────────────────────────────────────────────────── + +const ghHeaders = { + Authorization: `Bearer ${GH_TOKEN}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', +} async function triggerWorkflow(workflow: string, inputs: Record) { const res = await fetch(`${BASE_URL}/actions/workflows/${workflow}/dispatches`, { method: 'POST', - headers: { - Authorization: `Bearer ${GH_TOKEN}`, - Accept: 'application/vnd.github+json', - 'Content-Type': 'application/json', - 'X-GitHub-Api-Version': '2022-11-28', - }, + headers: ghHeaders, body: JSON.stringify({ ref: 'main', inputs }), }) - if (!res.ok) { - const text = await res.text() - throw new Error(`GitHub API error ${res.status}: ${text}`) - } + if (!res.ok) throw new Error(`GitHub API error ${res.status}: ${await res.text()}`) } +async function waitForWorkflow( + workflow: string, + afterTime: Date, + timeoutMs = 600_000, +): Promise<{ conclusion: string; url: string }> { + const deadline = Date.now() + timeoutMs + await Bun.sleep(8_000) // tunggu run muncul di API + + while (Date.now() < deadline) { + const res = await fetch(`${BASE_URL}/actions/workflows/${workflow}/runs?per_page=5`, { + headers: ghHeaders, + }) + const data = await res.json() as { workflow_runs: any[] } + const run = data.workflow_runs?.find( + (r: any) => new Date(r.created_at) >= afterTime, + ) + + if (run) { + if (run.status === 'completed') { + return { conclusion: run.conclusion ?? 'failure', url: run.html_url } + } + } + + await Bun.sleep(12_000) + } + + throw new Error(`Workflow ${workflow} timeout setelah ${timeoutMs / 1000}s`) +} + +// ─── Shell helpers ───────────────────────────────────────────────────────────── + +async function sh(cmd: string[]): Promise<{ out: string; err: string; ok: boolean }> { + const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe', cwd: process.cwd() }) + const [out, err, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + return { out: out.trim(), err: err.trim(), ok: code === 0 } +} + +// ─── MCP Server ──────────────────────────────────────────────────────────────── + const server = new McpServer({ name: 'deploy-stg', version: '1.0.0' }) +// ─── Tool: publish (manual, single step) ────────────────────────────────────── + server.tool( 'publish', - 'Build & push Docker image ke GHCR untuk environment staging (publish.yml)', + 'Trigger publish.yml untuk build & push Docker image staging', { tag: z.string().describe('Image tag, contoh: 1.0.0') }, async ({ tag }) => { await triggerWorkflow('publish.yml', { stack_env: 'stg', tag }) - return { content: [{ type: 'text', text: `Workflow publish.yml dipicu untuk stg-${tag}. Cek status di GitHub Actions.` }] } + return { content: [{ type: 'text', text: `✅ publish.yml dipicu → stg-${tag}` }] } }, ) +// ─── Tool: repull (manual, single step) ─────────────────────────────────────── + server.tool( 'repull', - 'Re-pull dan redeploy stack staging di Portainer (re-pull.yml)', + 'Trigger re-pull.yml untuk redeploy stack staging di Portainer', {}, async () => { await triggerWorkflow('re-pull.yml', { stack_name: STACK_NAME, stack_env: 'stg' }) - return { content: [{ type: 'text', text: `Workflow re-pull.yml dipicu untuk stack ${STACK_NAME}-stg. Cek status di GitHub Actions.` }] } + return { content: [{ type: 'text', text: `✅ re-pull.yml dipicu → ${STACK_NAME}-stg` }] } + }, +) + +// ─── Tool: deploy (full pipeline) ───────────────────────────────────────────── + +server.tool( + 'deploy', + [ + 'Full deploy pipeline ke staging:', + '1. Cek pending migrations', + '2. Version bump di package.json', + '3. Commit & push ke build/stg', + '4. Trigger publish.yml → tunggu selesai', + '5. Trigger re-pull.yml → tunggu selesai', + '6. Cek version di staging & local untuk konfirmasi', + ].join('\n'), + { tag: z.string().describe('Versi baru, contoh: 1.2.3') }, + async ({ tag }) => { + const log: string[] = [] + + // ── 1. Cek migrasi ────────────────────────────────────────────────────── + const migrate = await sh(['bunx', 'prisma', 'migrate', 'status']) + if (!migrate.ok || migrate.out.includes('not yet been applied')) { + return { + content: [{ + type: 'text', + text: [ + '❌ Deploy dibatalkan — ada pending migrations.', + '', + migrate.out || migrate.err, + '', + 'Jalankan `bun run db:migrate` terlebih dahulu.', + ].join('\n'), + }], + } + } + log.push('✅ Migrations: up to date') + + // ── 2. Version bump ────────────────────────────────────────────────────── + const pkgPath = `${process.cwd()}/package.json` + const pkg = await Bun.file(pkgPath).json() + const prevVersion = pkg.version as string + pkg.version = tag + await Bun.write(pkgPath, JSON.stringify(pkg, null, 2) + '\n') + log.push(`✅ Version bump: ${prevVersion} → ${tag}`) + + // ── 3. Commit & push build/stg ─────────────────────────────────────────── + await sh(['git', 'add', 'package.json']) + const commit = await sh(['git', 'commit', '-m', `chore: bump version to ${tag}`]) + if (!commit.ok) { + return { content: [{ type: 'text', text: `❌ git commit gagal:\n${commit.err}` }] } + } + log.push('✅ Committed') + + const push = await sh(['git', 'push', 'origin', 'HEAD:build/stg']) + if (!push.ok) { + return { content: [{ type: 'text', text: `❌ git push gagal:\n${push.err}` }] } + } + log.push('✅ Pushed → build/stg') + + // ── 4. Publish workflow ────────────────────────────────────────────────── + log.push('⏳ Menjalankan publish.yml...') + const publishTriggeredAt = new Date() + await triggerWorkflow('publish.yml', { stack_env: 'stg', tag }) + + const publish = await waitForWorkflow('publish.yml', publishTriggeredAt) + if (publish.conclusion !== 'success') { + return { + content: [{ + type: 'text', + text: [ + ...log, + `❌ publish.yml ${publish.conclusion}`, + `Detail: ${publish.url}`, + ].join('\n'), + }], + } + } + log.push(`✅ publish.yml sukses → ${publish.url}`) + + // ── 5. Re-pull workflow ────────────────────────────────────────────────── + log.push('⏳ Menjalankan re-pull.yml...') + const repullTriggeredAt = new Date() + await triggerWorkflow('re-pull.yml', { stack_name: STACK_NAME, stack_env: 'stg' }) + + const repull = await waitForWorkflow('re-pull.yml', repullTriggeredAt) + if (repull.conclusion !== 'success') { + return { + content: [{ + type: 'text', + text: [ + ...log, + `❌ re-pull.yml ${repull.conclusion}`, + `Detail: ${repull.url}`, + ].join('\n'), + }], + } + } + log.push(`✅ re-pull.yml sukses → ${repull.url}`) + + // ── 6. Cek version ─────────────────────────────────────────────────────── + await Bun.sleep(5_000) // tunggu container restart + log.push('⏳ Mengecek version di staging...') + + const localCommitProc = await sh(['git', 'rev-parse', '--short', 'HEAD']) + const localCommit = localCommitProc.out + + let stgInfo: { version?: string; commit?: string } = {} + try { + const versionRes = await fetch(`${STG_URL}${VERSION_PATH}`) + stgInfo = await versionRes.json() + } catch (e) { + log.push(`⚠️ Gagal mengecek version staging: ${e}`) + } + + const versionMatch = stgInfo.version === tag + const commitMatch = stgInfo.commit === localCommit + + log.push('') + log.push('─── Version Check ───────────────────────────') + log.push(`Local : version=${tag}, commit=${localCommit}`) + log.push(`Staging: version=${stgInfo.version ?? '?'}, commit=${stgInfo.commit ?? '?'}`) + log.push(versionMatch && commitMatch + ? '✅ Staging sudah terupdate dan sesuai local' + : `⚠️ Mismatch — version: ${versionMatch ? 'OK' : 'BEDA'}, commit: ${commitMatch ? 'OK' : 'BEDA'}`, + ) + + return { content: [{ type: 'text', text: log.join('\n') }] } }, )