chore: setup MCP deploy-stg + dokumentasi deployment

This commit is contained in:
2026-05-04 15:40:05 +08:00
parent 6b4dd91e0b
commit 48f73b627d
5 changed files with 195 additions and 88 deletions

View File

@@ -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`).

View File

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

View File

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

View File

@@ -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<T = unknown>(
pathname: string,
init: RequestInit = {}
): Promise<T> {
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<string, string>
): Promise<void> {
await ghFetch(`/repos/${REPO}/actions/workflows/${workflow}/dispatches`, {
method: "POST",
body: JSON.stringify({ ref, inputs }),
});
}
async function getLatestRun(workflow: string): Promise<WorkflowRun> {
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<WorkflowRun[]> {
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<WorkflowRun> {
const interval = 10_000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const run = await ghFetch<WorkflowRun>(
`/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<string> {
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"),
},

View File

@@ -6,7 +6,8 @@
"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"