diff --git a/.claude/DEPLOYMENT.md b/.claude/DEPLOYMENT.md index ae618c3..61df9e3 100644 --- a/.claude/DEPLOYMENT.md +++ b/.claude/DEPLOYMENT.md @@ -3,3 +3,37 @@ Docker images are built via `.github/workflows/publish.yml` and pushed to GHCR (`ghcr.io`). Portainer redeploys via `.github/workflows/re-pull.yml`. Supports `dev`, `stg`, and `prod` stacks. The Dockerfile uses a two-stage build: Bun builder → Bun runner (non-root user, port 3000). + +## Git Remote Structure + +| Remote | URL | Purpose | +|--------|-----|---------| +| `origin` | wibugit.wibudev.com/wibu/sistem-desa-mandiri | Repo kerja tim | +| `build` | github.com/bipprojectbali/desa-plus | Repo deployment (trigger CI/CD) | + +**Branch mapping:** +- `origin/staging` — branch integrasi tim (bukan deployment target) +- `build/stg` — branch deployment stg (trigger publish image + Portainer repull) +- `build/prod` — branch deployment prod +- `build/dev` — branch deployment dev + +## Deploy to STG Flow + +Cukup jalankan MCP `deploy-stg` — handles otomatis: cek migrasi → bump version → commit → push ke `build/stg` → trigger publish workflow (`ref: stg`) → tunggu selesai → trigger repull Portainer → verify version via `BASE_URL${VERSION_PATH}`. + +> `origin` tidak punya branch `stg` (hanya `staging`). "stg" selalu merujuk ke `build/stg`. + +## MCP `deploy-stg` + +Lokasi: `.mcp/deploy-stg/server.ts`. Berkomunikasi langsung dengan GitHub REST API (tidak butuh `gh` CLI), hanya perlu `git` & `prisma` lokal. + +**Env vars** (di `.mcp.json` atau `.env`): +- `GH_TOKEN` — PAT dengan scope `repo` + `workflow` untuk trigger Actions +- `GH_URL` — repo build target, format `owner/repo` atau full URL +- `BASE_URL` — base URL stg untuk verifikasi versi +- `VERSION_PATH` — endpoint cek versi (default `/api/version-app`) +- `STACK_NAME` — nama stack Portainer + +**Tools:** `deploy`, `publish`, `repull`, `run_status`, `check_version`. + +**Penting:** workflow `publish.yml` & `re-pull.yml` di-trigger dengan `ref: stg` agar `actions/checkout@v4` checkout dari branch `stg`, bukan default branch (`main`). diff --git a/.claude/ENV.md b/.claude/ENV.md index a1b370d..22c0d31 100644 --- a/.claude/ENV.md +++ b/.claude/ENV.md @@ -10,3 +10,15 @@ Copy `.env.example` to `.env`. Required variables: | `WS_APIKEY` | WebSocket/file storage API key | | `WIBU_REALTIME_KEY` | Real-time communication | | `FCM_KEY` | Firebase Cloud Messaging | + +## Deployment (MCP `deploy-stg`) + +Diisi di `.env` lokal (jangan commit `GH_TOKEN`). `.mcp.json` me-reference via `${GH_TOKEN}`. + +| Variable | Purpose | +|---|---| +| `GH_TOKEN` | GitHub PAT dengan scope `repo` + `workflow` | +| `GH_URL` | Repo build target (`owner/repo` atau full URL) | +| `BASE_URL` | Base URL deployment stg (untuk verifikasi versi) | +| `VERSION_PATH` | Endpoint cek versi (default `/api/version-app`) | +| `STACK_NAME` | Nama stack di Portainer | diff --git a/.mcp.json b/.mcp.json index e2db79e..7cc17fa 100644 --- a/.mcp.json +++ b/.mcp.json @@ -5,9 +5,13 @@ "command": "bun", "args": ["run", ".mcp/deploy-stg/server.ts"], "env": { + "GH_TOKEN": "${GH_TOKEN}", + "GH_URL": "bipprojectbali/desa-plus", "BASE_URL": "https://desa-plus-stg.wibudev.com", + "VERSION_PATH": "/api/version-app", "STACK_NAME": "desa-plus" } } } } + diff --git a/.mcp/deploy-stg/server.ts b/.mcp/deploy-stg/server.ts index c84ea4f..0e584dc 100644 --- a/.mcp/deploy-stg/server.ts +++ b/.mcp/deploy-stg/server.ts @@ -4,24 +4,127 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; -import { execFileSync, execSync } from "child_process"; +import { execFileSync } from "child_process"; import { readFileSync, writeFileSync } from "fs"; import path from "path"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, "../.."); -const REPO = "bipprojectbali/desa-plus"; const STACK_ENV = "stg"; const BASE_URL = process.env.BASE_URL ?? ""; +const VERSION_PATH = process.env.VERSION_PATH ?? "/api/version-app"; const DEFAULT_STACK_NAME = process.env.STACK_NAME ?? ""; +const GH_TOKEN = process.env.GH_TOKEN ?? ""; -const GH = (args: string[]) => - execFileSync("gh", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim(); +const GH_URL_RAW = process.env.GH_URL ?? ""; +// support both "owner/repo" and "https://github.com/owner/repo" formats +const REPO = GH_URL_RAW.startsWith("http") + ? GH_URL_RAW.replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "") + : GH_URL_RAW; const GIT = (args: string[]) => execFileSync("git", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim(); +// --- GitHub API client (no gh CLI) --- + +const GH_API = "https://api.github.com"; + +type WorkflowRun = { + id: number; + name: string; + status: string; + conclusion: string | null; + html_url: string; + created_at: string; + run_started_at: string; +}; + +async function ghFetch( + pathname: string, + init: RequestInit = {} +): Promise { + if (!GH_TOKEN) throw new Error("GH_TOKEN tidak di-set."); + if (!REPO) throw new Error("GH_URL tidak di-set."); + + const res = await fetch(`${GH_API}${pathname}`, { + ...init, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${GH_TOKEN}`, + "X-GitHub-Api-Version": "2022-11-28", + ...(init.body ? { "Content-Type": "application/json" } : {}), + ...(init.headers ?? {}), + }, + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`GitHub API ${res.status} ${res.statusText}: ${body}`); + } + + // 204 No Content (e.g. workflow dispatch) + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +async function triggerWorkflow( + workflow: string, + ref: string, + inputs: Record +): Promise { + await ghFetch(`/repos/${REPO}/actions/workflows/${workflow}/dispatches`, { + method: "POST", + body: JSON.stringify({ ref, inputs }), + }); +} + +async function getLatestRun(workflow: string): Promise { + const data = await ghFetch<{ workflow_runs: WorkflowRun[] }>( + `/repos/${REPO}/actions/workflows/${workflow}/runs?per_page=1` + ); + if (!data.workflow_runs?.length) { + throw new Error(`Tidak ada run untuk workflow ${workflow}.`); + } + return data.workflow_runs[0]; +} + +async function listRuns( + workflow: string | "all", + limit: number +): Promise { + const url = + workflow === "all" + ? `/repos/${REPO}/actions/runs?per_page=${limit}` + : `/repos/${REPO}/actions/workflows/${workflow}/runs?per_page=${limit}`; + const data = await ghFetch<{ workflow_runs: WorkflowRun[] }>(url); + return data.workflow_runs ?? []; +} + +async function waitForRun( + runId: number, + timeoutMs = 30 * 60 * 1000 +): Promise { + const interval = 10_000; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const run = await ghFetch( + `/repos/${REPO}/actions/runs/${runId}` + ); + if (run.status === "completed") { + if (run.conclusion !== "success") { + throw new Error( + `Run ${runId} selesai dengan conclusion: ${run.conclusion}` + ); + } + return run; + } + await new Promise((r) => setTimeout(r, interval)); + } + throw new Error(`Timeout menunggu run ${runId}.`); +} + // --- version helpers --- function bumpVersion(version: string, type: "patch" | "minor" | "major"): string { @@ -48,7 +151,7 @@ function applyVersionBump(newVersion: string): void { async function waitForDeployedVersion(expected: string, timeoutMs = 5 * 60 * 1000): Promise { if (!BASE_URL) return "BASE_URL tidak di-set, skip cek versi stg."; - const url = `${BASE_URL}/api/version-app`; + const url = `${BASE_URL}${VERSION_PATH}`; const interval = 15_000; const maxAttempts = Math.ceil(timeoutMs / interval); let last = ""; @@ -199,54 +302,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const currentBranch = GIT(["rev-parse", "--abbrev-ref", "HEAD"]); GIT(["push", "build", `${currentBranch}:stg`, "--force"]); - // 4. Trigger publish - GH([ - "workflow", "run", "publish.yml", - "--repo", REPO, - "--field", `stack_env=${STACK_ENV}`, - "--field", `tag=${newVersion}`, - ]); + // 4. Trigger publish workflow + await triggerWorkflow("publish.yml", STACK_ENV, { + stack_env: STACK_ENV, + tag: newVersion, + }); await new Promise((r) => setTimeout(r, 4000)); - const publishRunId = GH([ - "run", "list", "--repo", REPO, - "--workflow", "publish.yml", - "--limit", "1", - "--json", "databaseId", - "--jq", ".[0].databaseId", - ]); - const publishUrl = GH([ - "run", "list", "--repo", REPO, - "--workflow", "publish.yml", - "--limit", "1", - "--json", "url", - "--jq", ".[0].url", - ]); + const publishRun = await getLatestRun("publish.yml"); // 5. Wait for publish to finish - execSync(`gh run watch ${publishRunId} --repo ${REPO} --exit-status`, { - encoding: "utf-8", - cwd: PROJECT_ROOT, - timeout: 30 * 60 * 1000, - stdio: "pipe", - }); + await waitForRun(publishRun.id); // 6. Trigger repull - GH([ - "workflow", "run", "re-pull.yml", - "--repo", REPO, - "--field", `stack_name=${stack_name}`, - "--field", `stack_env=${STACK_ENV}`, - ]); + await triggerWorkflow("re-pull.yml", STACK_ENV, { + stack_name, + stack_env: STACK_ENV, + }); await new Promise((r) => setTimeout(r, 4000)); - const repullUrl = GH([ - "run", "list", "--repo", REPO, - "--workflow", "re-pull.yml", - "--limit", "1", - "--json", "url", - "--jq", ".[0].url", - ]); + const repullRun = await getLatestRun("re-pull.yml"); // 7. Wait for repull, then verify version await new Promise((r) => setTimeout(r, 30_000)); @@ -260,8 +335,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { type: "text", text: [ `Deploy selesai: ${stack_name}-${STACK_ENV} @ ${newVersion} (dari ${oldVersion})`, - `Publish run : ${publishUrl}`, - `Repull run : ${repullUrl}`, + `Publish run : ${publishRun.html_url}`, + `Repull run : ${repullRun.html_url}`, ``, `Versi lokal : ${localVer}`, versionCheck, @@ -275,24 +350,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (name === "publish") { const tag = readPkgVersion(); - GH([ - "workflow", "run", "publish.yml", - "--repo", REPO, - "--field", `stack_env=${STACK_ENV}`, - "--field", `tag=${tag}`, - ]); + await triggerWorkflow("publish.yml", STACK_ENV, { + stack_env: STACK_ENV, + tag, + }); await new Promise((r) => setTimeout(r, 3000)); - const runUrl = GH([ - "run", "list", "--repo", REPO, - "--workflow", "publish.yml", - "--limit", "1", - "--json", "url", - "--jq", ".[0].url", - ]); + const run = await getLatestRun("publish.yml"); return { - content: [{ type: "text", text: `Publish triggered: ${STACK_ENV}-${tag}\nRun: ${runUrl}` }], + content: [{ type: "text", text: `Publish triggered: ${STACK_ENV}-${tag}\nRun: ${run.html_url}` }], }; } @@ -302,24 +369,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const stack_name = _sn || DEFAULT_STACK_NAME; if (!stack_name) throw new Error("stack_name tidak diisi dan env STACK_NAME kosong."); - GH([ - "workflow", "run", "re-pull.yml", - "--repo", REPO, - "--field", `stack_name=${stack_name}`, - "--field", `stack_env=${STACK_ENV}`, - ]); + await triggerWorkflow("re-pull.yml", STACK_ENV, { + stack_name, + stack_env: STACK_ENV, + }); await new Promise((r) => setTimeout(r, 3000)); - const runUrl = GH([ - "run", "list", "--repo", REPO, - "--workflow", "re-pull.yml", - "--limit", "1", - "--json", "url", - "--jq", ".[0].url", - ]); + const run = await getLatestRun("re-pull.yml"); return { - content: [{ type: "text", text: `Repull triggered: ${stack_name}-${STACK_ENV}\nRun: ${runUrl}` }], + content: [{ type: "text", text: `Repull triggered: ${stack_name}-${STACK_ENV}\nRun: ${run.html_url}` }], }; } @@ -329,17 +388,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { workflow?: string; limit?: number; }; - const workflowArgs = workflow === "all" ? [] : ["--workflow", workflow]; - const output = GH([ - "run", "list", - "--repo", REPO, - ...workflowArgs, - "--limit", String(limit), - "--json", "workflowName,status,conclusion,startedAt,url,databaseId", - "--jq", - '.[] | "[\(.status)/\(.conclusion // "-")] \(.workflowName) — \(.startedAt)\n \(.url)"', - ]); + const runs = await listRuns(workflow, limit); + const output = runs + .map( + (r) => + `[${r.status}/${r.conclusion ?? "-"}] ${r.name} — ${r.run_started_at}\n ${r.html_url}` + ) + .join("\n"); return { content: [{ type: "text", text: output || "Tidak ada run ditemukan." }], @@ -353,7 +409,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (BASE_URL) { try { - const res = await fetch(`${BASE_URL}/api/version-app`); + const res = await fetch(`${BASE_URL}${VERSION_PATH}`); const data = (await res.json()) as { version?: string }; stgVersion = data.version ?? "?"; } catch (e) { @@ -371,7 +427,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { type: "text", text: [ `Lokal (package.json) : ${localVersion}`, - `Stg (/api/version-app): ${stgVersion}`, + `Stg (${VERSION_PATH}): ${stgVersion}`, `Status : ${match}`, ].join("\n"), }, diff --git a/package.json b/package.json index c7553a2..2425e92 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "sistem-desa-mandiri", - "version": "0.1.7", + "version": "0.1.10", "private": true, "scripts": { "dev": "next dev --experimental-https", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "claude": "set -a && source .env && set +a && claude" }, "prisma": { "seed": "npx tsx prisma/seed.ts" diff --git a/prisma/migrations/20260504064301_auto/migration.sql b/prisma/migrations/20260504064301_auto/migration.sql new file mode 100644 index 0000000..af5102c --- /dev/null +++ b/prisma/migrations/20260504064301_auto/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file diff --git a/prisma/migrations/20260504064957_auto/migration.sql b/prisma/migrations/20260504064957_auto/migration.sql new file mode 100644 index 0000000..af5102c --- /dev/null +++ b/prisma/migrations/20260504064957_auto/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file diff --git a/prisma/migrations/20260504074029_auto/migration.sql b/prisma/migrations/20260504074029_auto/migration.sql new file mode 100644 index 0000000..af5102c --- /dev/null +++ b/prisma/migrations/20260504074029_auto/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file