Sebelumnya pipeline dibatalkan saat ada pending migrations. Sekarang langsung deploy migrations lalu lanjut ke step berikutnya. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
232 lines
9.4 KiB
TypeScript
232 lines
9.4 KiB
TypeScript
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 ?? '' // 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: ghHeaders,
|
|
body: JSON.stringify({ ref: 'main', inputs }),
|
|
})
|
|
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',
|
|
'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: `✅ publish.yml dipicu → stg-${tag}` }] }
|
|
},
|
|
)
|
|
|
|
// ─── Tool: repull (manual, single step) ───────────────────────────────────────
|
|
|
|
server.tool(
|
|
'repull',
|
|
'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: `✅ 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 & jalankan migrasi jika ada ─────────────────────────────────
|
|
const migrateStatus = await sh(['bunx', 'prisma', 'migrate', 'status'])
|
|
if (!migrateStatus.ok || migrateStatus.out.includes('not yet been applied')) {
|
|
log.push('⏳ Ada pending migrations — menjalankan migrate deploy...')
|
|
const migrateRun = await sh(['bunx', 'prisma', 'migrate', 'deploy'])
|
|
if (!migrateRun.ok) {
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: [
|
|
...log,
|
|
'❌ Migrate deploy gagal:',
|
|
migrateRun.err || migrateRun.out,
|
|
].join('\n'),
|
|
}],
|
|
}
|
|
}
|
|
log.push('✅ Migrations: deployed')
|
|
} else {
|
|
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') }] }
|
|
},
|
|
)
|
|
|
|
const transport = new StdioServerTransport()
|
|
await server.connect(transport)
|