diff --git a/.claude/commands/deploy-stg.md b/.claude/commands/deploy-stg.md new file mode 100644 index 00000000..1e9cd24e --- /dev/null +++ b/.claude/commands/deploy-stg.md @@ -0,0 +1,175 @@ +# deploy-stg + +Deploy ke staging environment secara penuh: version bump, cek migrasi, commit, push, trigger GitHub workflow (publish + re-pull), dan verifikasi versi. + +**Repo GitHub:** `bipproductbali/desa-darmasaba` +**Branch stg:** `stg` +**STG URL:** `https://desa-darmasaba-stg.wibudev.com` + +--- + +## Alur Eksekusi + +### Langkah 0 — Cek /api/version +Sebelum apapun, pastikan endpoint `/api/version` sudah ada: +```bash +grep -n '"/version"' src/app/api/\[\[...slugs\]\]/route.ts +``` +Jika belum ada, tambahkan langsung ke main API group (referensi dari package.json via `fs.readFile`). + +--- + +### Langkah 1 — Version Bump + +Baca versi saat ini dari `package.json`, bump patch version (+1), lalu tulis ulang: +```bash +# Baca versi saat ini +node -e "const p=require('./package.json'); const [maj,min,pat]=p.version.split('.').map(Number); console.log(maj+'.'+min+'.'+(pat+1))" +``` +Update `package.json` dengan versi baru. Simpan versi baru sebagai `$NEW_VERSION`. + +--- + +### Langkah 2 — Cek Prisma Migration + +```bash +bunx prisma migrate status +``` + +- Jika output mengandung kata `pending` atau `drift` → buat migrasi baru: + ```bash + bunx prisma migrate dev --name bump-stg- + ``` +- Jika sudah up-to-date → lanjut ke langkah berikutnya. + +--- + +### Langkah 3 — Build Check + +```bash +bun run build +``` + +Jika build gagal, **stop** dan perbaiki error dulu sebelum melanjutkan deploy. + +--- + +### Langkah 4 — Commit + +Stage semua perubahan dan commit: +```bash +git add package.json +git add prisma/migrations/ # jika ada migrasi baru +git commit -m "chore: bump version to $NEW_VERSION for stg deploy" +``` + +--- + +### Langkah 5 — Push ke origin/stg + +```bash +git push origin HEAD:stg +``` + +Tunggu push selesai sebelum trigger workflow. + +--- + +### Langkah 6 — Trigger publish.yml + +**Input 1:** `stack_env` = `stg` +**Input 2:** `tag` = versi dari package.json (contoh: `0.1.25`) + +```bash +gh workflow run publish.yml \ + --repo bipprojectbali/desa-darmasaba \ + --ref stg \ + -f stack_env=stg \ + -f tag=$NEW_VERSION +``` + +Tunggu 5 detik lalu dapatkan run ID: +```bash +sleep 5 +RUN_ID=$(gh run list \ + --workflow=publish.yml \ + --repo bipprojectbali/desa-darmasaba \ + --limit 1 \ + --json databaseId \ + -q '.[0].databaseId') +``` + +Monitor sampai selesai: +```bash +gh run watch $RUN_ID --repo bipprojectbali/desa-darmasaba +``` + +Jika publish **gagal** → **stop**, jangan lanjut ke re-pull. + +--- + +### Langkah 7 — Trigger re-pull.yml + +Setelah publish berhasil, trigger re-pull: + +**Input 1:** `stack_env` = `stg` +**Input 2:** `stack_name` = `desa-darmasaba` → stack yang di-deploy: `desa-darmasaba-stg` + +```bash +gh workflow run re-pull.yml \ + --repo bipprojectbali/desa-darmasaba \ + --ref main \ + -f stack_name=desa-darmasaba \ + -f stack_env=stg +``` + +Tunggu 5 detik lalu monitor: +```bash +sleep 5 +REPULL_ID=$(gh run list \ + --workflow=re-pull.yml \ + --repo bipprojectbali/desa-darmasaba \ + --limit 1 \ + --json databaseId \ + -q '.[0].databaseId') +gh run watch $REPULL_ID --repo bipprojectbali/desa-darmasaba +``` + +--- + +### Langkah 8 — Verifikasi Versi + +Bandingkan versi di stg dengan versi lokal: + +```bash +# Versi lokal +LOCAL_VER=$(node -e "console.log(require('./package.json').version)") + +# Versi di STG (tunggu container siap ~30 detik) +sleep 30 +STG_VER=$(curl -s https://desa-darmasaba-stg.wibudev.com/api/version | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).version))") + +echo "Local : $LOCAL_VER" +echo "STG : $STG_VER" +``` + +- Jika `LOCAL_VER == STG_VER` → **Deploy berhasil!** +- Jika berbeda → cek logs container di Portainer atau jalankan `gh run view $REPULL_ID --repo bipprojectbali/desa-darmasaba --log` + +--- + +## Ringkasan Workflow Inputs + +| Workflow | Input | Value | +|----------|-------|-------| +| `publish.yml` | `stack_env` | `stg` | +| `publish.yml` | `tag` | versi dari `package.json` (e.g. `0.1.25`) | +| `re-pull.yml` | `stack_name` | `desa-darmasaba` | +| `re-pull.yml` | `stack_env` | `stg` | + +## Catatan + +- Jangan jalankan `re-pull.yml` jika `publish.yml` belum selesai/berhasil. +- Verifikasi versi dilakukan via `/api/version` (bukan `/api/utils/version`). +- Jika `gh` belum login: `gh auth login`. +- Untuk cek status workflow manual: `gh run list --repo bipprojectbali/desa-darmasaba`. diff --git a/.claude/mcp/github-actions.mjs b/.claude/mcp/github-actions.mjs new file mode 100644 index 00000000..b0b0c7f9 --- /dev/null +++ b/.claude/mcp/github-actions.mjs @@ -0,0 +1,258 @@ +#!/usr/bin/env node +/** + * MCP Server: GitHub Actions Workflow Tools + * Tools: trigger_publish, trigger_repull, get_workflow_runs, watch_workflow_run + */ + +import { execSync, spawnSync } from "child_process"; + +const REPO = "bipprojectbali/desa-darmasaba"; + +// --- MCP Protocol Helpers --- + +function send(obj) { + process.stdout.write(JSON.stringify(obj) + "\n"); +} + +function respond(id, result) { + send({ jsonrpc: "2.0", id, result }); +} + +function respondError(id, code, message) { + send({ jsonrpc: "2.0", id, error: { code, message } }); +} + +// --- Shell Helper --- + +function runCmd(cmd) { + const r = spawnSync("sh", ["-c", cmd], { encoding: "utf-8" }); + if (r.error) return { ok: false, out: r.error.message }; + if (r.status !== 0) return { ok: false, out: (r.stderr || r.stdout || "").trim() }; + return { ok: true, out: (r.stdout || "").trim() }; +} + +function getLatestRunId(workflow, delaySecs = 3) { + const r = runCmd( + `sleep ${delaySecs} && gh run list --workflow=${workflow} --repo ${REPO} --limit 1 --json databaseId -q '.[0].databaseId'` + ); + return r.ok ? r.out.trim() : null; +} + +// --- Tool Definitions --- + +const TOOLS = [ + { + name: "trigger_publish", + description: + "Trigger publish.yml workflow: build & push Docker image to GHCR. Returns run ID.", + inputSchema: { + type: "object", + properties: { + stack_env: { + type: "string", + enum: ["dev", "stg", "prod"], + description: "Target environment (branch dengan nama ini akan di-checkout)", + }, + tag: { + type: "string", + description: "Image tag, biasanya versi dari package.json (e.g. 0.1.25)", + }, + }, + required: ["stack_env", "tag"], + }, + }, + { + name: "trigger_repull", + description: + "Trigger re-pull.yml workflow: redeploy stack di Portainer. Hanya jalankan setelah publish berhasil.", + inputSchema: { + type: "object", + properties: { + stack_name: { + type: "string", + description: "Nama stack (e.g. desa-darmasaba). Stack yang di-deploy: -", + }, + stack_env: { + type: "string", + enum: ["dev", "stg", "prod"], + description: "Target environment", + }, + }, + required: ["stack_name", "stack_env"], + }, + }, + { + name: "get_workflow_runs", + description: "List run terbaru dari suatu workflow beserta status dan conclusion-nya.", + inputSchema: { + type: "object", + properties: { + workflow: { + type: "string", + description: "Nama file workflow (e.g. publish.yml, re-pull.yml)", + }, + limit: { + type: "number", + description: "Jumlah run yang ditampilkan (default: 5)", + }, + }, + required: ["workflow"], + }, + }, + { + name: "watch_workflow_run", + description: + "Lihat status detail sebuah workflow run: apakah in_progress, success, atau failure.", + inputSchema: { + type: "object", + properties: { + run_id: { + type: "number", + description: "ID workflow run (dapat dari get_workflow_runs)", + }, + }, + required: ["run_id"], + }, + }, +]; + +// --- Tool Handlers --- + +function handleTriggerPublish(args) { + const { stack_env, tag } = args; + const triggerCmd = `gh workflow run publish.yml --repo ${REPO} --ref ${stack_env} -f stack_env=${stack_env} -f tag=${tag}`; + const r = runCmd(triggerCmd); + if (!r.ok) return { ok: false, error: r.out }; + + const runId = getLatestRunId("publish.yml"); + return { + ok: true, + message: `Workflow publish.yml berhasil di-trigger (env=${stack_env}, tag=${tag})`, + run_id: runId ? Number(runId) : null, + monitor_hint: runId + ? `Gunakan watch_workflow_run dengan run_id: ${runId}` + : "Gunakan get_workflow_runs untuk mendapatkan run_id terbaru", + }; +} + +function handleTriggerRepull(args) { + const { stack_name, stack_env } = args; + const triggerCmd = `gh workflow run re-pull.yml --repo ${REPO} --ref main -f stack_name=${stack_name} -f stack_env=${stack_env}`; + const r = runCmd(triggerCmd); + if (!r.ok) return { ok: false, error: r.out }; + + const runId = getLatestRunId("re-pull.yml"); + return { + ok: true, + message: `Workflow re-pull.yml berhasil di-trigger (stack=${stack_name}-${stack_env})`, + run_id: runId ? Number(runId) : null, + monitor_hint: runId + ? `Gunakan watch_workflow_run dengan run_id: ${runId}` + : "Gunakan get_workflow_runs untuk mendapatkan run_id terbaru", + }; +} + +function handleGetWorkflowRuns(args) { + const limit = args.limit || 5; + const r = runCmd( + `gh run list --workflow=${args.workflow} --repo ${REPO} --limit ${limit} --json databaseId,status,conclusion,displayTitle,createdAt,headBranch` + ); + if (!r.ok) return { ok: false, error: r.out }; + try { + return { ok: true, runs: JSON.parse(r.out) }; + } catch { + return { ok: false, error: "Gagal parse output", raw: r.out }; + } +} + +function handleWatchWorkflowRun(args) { + const r = runCmd( + `gh run view ${args.run_id} --repo ${REPO} --json status,conclusion,displayTitle,headBranch,createdAt,updatedAt,jobs` + ); + if (!r.ok) return { ok: false, error: r.out }; + try { + const data = JSON.parse(r.out); + return { + ok: true, + run_id: args.run_id, + status: data.status, + conclusion: data.conclusion, + title: data.displayTitle, + branch: data.headBranch, + jobs: (data.jobs || []).map((j) => ({ + name: j.name, + status: j.status, + conclusion: j.conclusion, + })), + }; + } catch { + return { ok: false, error: "Gagal parse output", raw: r.out }; + } +} + +// --- Request Router --- + +function handleMessage(msg) { + const { id, method, params } = msg; + + if (method === "initialize") { + respond(id, { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "github-actions", version: "1.0.0" }, + }); + return; + } + + if (method === "notifications/initialized") return; + + if (method === "ping") { + respond(id, {}); + return; + } + + if (method === "tools/list") { + respond(id, { tools: TOOLS }); + return; + } + + if (method === "tools/call") { + const { name, arguments: args = {} } = params; + let result; + + if (name === "trigger_publish") result = handleTriggerPublish(args); + else if (name === "trigger_repull") result = handleTriggerRepull(args); + else if (name === "get_workflow_runs") result = handleGetWorkflowRuns(args); + else if (name === "watch_workflow_run") result = handleWatchWorkflowRun(args); + else { + respondError(id, -32601, `Tool tidak ditemukan: ${name}`); + return; + } + + respond(id, { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }); + return; + } + + respondError(id, -32601, `Method tidak dikenal: ${method}`); +} + +// --- Stdin Loop --- + +let buf = ""; +process.stdin.setEncoding("utf-8"); +process.stdin.on("data", (chunk) => { + buf += chunk; + const lines = buf.split("\n"); + buf = lines.pop(); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + handleMessage(JSON.parse(trimmed)); + } catch { + // ignore malformed JSON + } + } +}); diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..69e1f5b2 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "github": { + "command": "sh", + "args": [ + "-c", + "GITHUB_PERSONAL_ACCESS_TOKEN=$(gh auth token) npx -y @modelcontextprotocol/server-github" + ] + }, + "github-actions": { + "command": "node", + "args": [".claude/mcp/github-actions.mjs"] + } + } +} diff --git a/package.json b/package.json index 386c7c9f..f669fdda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "desa-darmasaba", - "version": "0.1.24", + "version": "0.1.25", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/api/[[...slugs]]/route.ts b/src/app/api/[[...slugs]]/route.ts index e3b26da0..0fcdc1d3 100644 --- a/src/app/api/[[...slugs]]/route.ts +++ b/src/app/api/[[...slugs]]/route.ts @@ -87,6 +87,13 @@ const ApiServer = new Elysia() .group("/api", (app) => app .use(Utils) + .get("/version", async () => { + const packageJson = await fs.readFile( + path.join(ROOT, "package.json"), + "utf-8", + ); + return { version: JSON.parse(packageJson).version }; + }) .use(FileStorage) .use(LandingPage) .use(PPID)