feat: tambah deploy pipeline tool di MCP deploy-stg

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 16:12:22 +08:00
parent d3a4f97d0e
commit 7609204a13
2 changed files with 200 additions and 20 deletions

View File

@@ -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"
}
}
}

View File

@@ -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<string, string>) {
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') }] }
},
)