This commit is contained in:
Kerod-Fresenbet-Gebremedhin2660 2026-01-30 17:30:42 +03:00
commit 25f1a76803
13 changed files with 1522 additions and 0 deletions

View File

@ -0,0 +1,111 @@
---
description: Use Bun instead of Node.js, npm, pnpm, or vite.
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
alwaysApply: false
---
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
// import .css files directly and it works
import './index.css';
import { createRoot } from "react-dom/client";
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.

34
.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

35
README.md Normal file
View File

@ -0,0 +1,35 @@
# yimaru-cicd
Gitea webhook CD server for:
- Yimaru admin repository (git pull)
- Yimaru backend repository (git pull)
## Setup
Install dependencies:
```bash
bun install
```
Create a `.env` with at least:
```
PORT=3000
PRODUCTION_BRANCH=main
GITEA_WEBHOOK_SECRET=...
GITEA_WEBHOOK_AUTH_HEADER=...
GITEA_USERNAME=...
GITEA_PASSWORD=...
YIMARU_ADMIN_PATH=/srv/apps/yimaru_admin
YIMARU_BACKEND_PATH=/srv/apps/Yimaru-BackEnd
```
Run locally:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.3.2. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

31
bun.lock Normal file
View File

@ -0,0 +1,31 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "yimaru-cicd",
"dependencies": {
"hono": "^4.11.7",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}

268
components/Layout.ts Normal file
View File

@ -0,0 +1,268 @@
interface LayoutProps {
title: string;
children: string;
}
const styles = `
:root {
--primary-green: #0F7B4A;
--primary-green-dark: #0a5c37;
--secondary-orange: #FFB668;
--secondary-orange-dark: #e9a050;
--error-red: #EF4444;
--error-red-light: #FEE2E2;
--text-dark: #1a1a1a;
--text-muted: #6b7280;
--bg-white: #ffffff;
--bg-light: #f9fafb;
--border-light: #e5e7eb;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: var(--bg-light);
color: var(--text-dark);
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background: var(--primary-green);
color: white;
padding: 20px 0;
margin-bottom: 30px;
box-shadow: 0 2px 8px rgba(15, 123, 74, 0.3);
}
header h1 {
font-size: 24px;
font-weight: 600;
}
nav {
margin-top: 10px;
}
nav a {
color: rgba(255, 255, 255, 0.9);
text-decoration: none;
margin-right: 20px;
padding: 6px 14px;
border-radius: 6px;
transition: all 0.2s ease;
font-weight: 500;
}
nav a:hover {
background: rgba(255, 255, 255, 0.15);
color: white;
}
h2, h3 {
color: var(--primary-green);
}
.card {
background: var(--bg-white);
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid var(--border-light);
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-success {
background: var(--primary-green);
color: white;
}
.badge-failed {
background: var(--error-red);
color: white;
}
.badge-in_progress, .badge-pending {
background: var(--secondary-orange);
color: var(--text-dark);
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 14px 12px;
text-align: left;
border-bottom: 1px solid var(--border-light);
}
th {
background: var(--bg-light);
font-weight: 600;
color: var(--text-dark);
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
tr:hover {
background: var(--bg-light);
}
.command {
background: var(--bg-light);
padding: 16px;
border-radius: 8px;
margin-bottom: 16px;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 13px;
border: 1px solid var(--border-light);
}
.command-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 8px;
}
.command-header strong {
color: var(--text-dark);
word-break: break-all;
}
.command-text {
color: var(--text-muted);
margin-bottom: 10px;
}
.command-output, .command-error {
margin-top: 12px;
padding: 12px;
border-radius: 6px;
max-height: 300px;
overflow: auto;
}
.command-output {
background: #f0fdf4;
border: 1px solid #bbf7d0;
}
.command-output strong {
color: var(--primary-green);
display: block;
margin-bottom: 8px;
}
.command-output pre {
color: var(--primary-green-dark);
white-space: pre-wrap;
word-break: break-word;
margin: 0;
font-family: inherit;
font-size: 12px;
line-height: 1.5;
}
.command-error {
background: var(--error-red-light);
border: 1px solid #fecaca;
}
.command-error strong {
color: var(--error-red);
display: block;
margin-bottom: 8px;
}
.command-error pre {
color: #991b1b;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
font-family: inherit;
font-size: 12px;
line-height: 1.5;
}
.timestamp {
color: var(--text-muted);
font-size: 13px;
}
a {
color: var(--primary-green);
text-decoration: none;
font-weight: 500;
}
a:hover {
color: var(--primary-green-dark);
text-decoration: underline;
}
code {
background: var(--bg-light);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 12px;
border: 1px solid var(--border-light);
}
.btn {
display: inline-block;
padding: 8px 16px;
border-radius: 6px;
font-weight: 500;
text-decoration: none;
transition: all 0.2s ease;
cursor: pointer;
border: none;
}
.btn-primary {
background: var(--primary-green);
color: white;
}
.btn-primary:hover {
background: var(--primary-green-dark);
text-decoration: none;
color: white;
}
.btn-secondary {
background: var(--secondary-orange);
color: var(--text-dark);
}
.btn-secondary:hover {
background: var(--secondary-orange-dark);
text-decoration: none;
}
.btn-danger {
background: var(--error-red);
color: white;
}
.btn-danger:hover {
background: #dc2626;
text-decoration: none;
color: white;
}
`;
export function Layout({ title, children }: LayoutProps): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title} - Yimaru CI/CD</title>
<style>${styles}</style>
</head>
<body>
<header>
<div class="container">
<h1>🚀 Yimaru CI/CD Dashboard</h1>
<nav>
<a href="/">Deployments</a>
<a href="/health">Health</a>
</nav>
</div>
</header>
<div class="container">
${children}
</div>
</body>
</html>`;
}

148
db.ts Normal file
View File

@ -0,0 +1,148 @@
import { Database } from "bun:sqlite";
export interface Deployment {
id?: number;
created_at?: string;
repository: string;
branch?: string;
commit_hash?: string;
status: string;
}
export interface Command {
id?: number;
deployment_id: number;
command: string;
stdout?: string;
stderr?: string;
exit_code?: number;
success: boolean;
}
let db: Database;
/**
* Initialize SQLite database with schema
*/
export function initializeDatabase(dbPath?: string): Database {
db = new Database(dbPath || "deployments.db");
// Create deployments table
db.exec(`
CREATE TABLE IF NOT EXISTS deployments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
repository TEXT NOT NULL,
branch TEXT,
commit_hash TEXT,
status TEXT NOT NULL
)
`);
// Create commands table
db.exec(`
CREATE TABLE IF NOT EXISTS commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deployment_id INTEGER,
command TEXT,
stdout TEXT,
stderr TEXT,
exit_code INTEGER,
success BOOLEAN,
FOREIGN KEY(deployment_id) REFERENCES deployments(id)
)
`);
console.log("✅ Database initialized successfully");
return db;
}
/**
* Get database instance
*/
export function getDb(): Database {
if (!db) {
throw new Error("Database not initialized. Call initializeDatabase first.");
}
return db;
}
/**
* Create a deployment record in the database
*/
export function createDeployment(
repository: string,
branch?: string,
commitHash?: string,
status: string = "in_progress"
): number {
const insertDeployment = getDb().prepare(`
INSERT INTO deployments (repository, branch, commit_hash, status)
VALUES (?, ?, ?, ?)
`);
const result = insertDeployment.run(repository, branch || null, commitHash || null, status);
return result.lastInsertRowid as number;
}
/**
* Update deployment status
*/
export function updateDeploymentStatus(deploymentId: number, status: string): void {
const updateDeployment = getDb().prepare(`
UPDATE deployments SET status = ? WHERE id = ?
`);
updateDeployment.run(status, deploymentId);
}
/**
* Insert a command record
*/
export function insertCommand(
deploymentId: number,
command: string,
stdout: string,
stderr: string | null,
exitCode: number,
success: boolean
): void {
const insertCmd = getDb().prepare(`
INSERT INTO commands (deployment_id, command, stdout, stderr, exit_code, success)
VALUES (?, ?, ?, ?, ?, ?)
`);
insertCmd.run(deploymentId, command, stdout, stderr, exitCode, success ? 1 : 0);
}
/**
* Get deployments with optional filtering
*/
export function getDeployments(limit: number = 50, repository?: string): Deployment[] {
let query = "SELECT * FROM deployments";
const params: any[] = [];
if (repository) {
query += " WHERE repository = ?";
params.push(repository);
}
query += " ORDER BY created_at DESC LIMIT ?";
params.push(limit);
const stmt = getDb().prepare(query);
return stmt.all(...params) as Deployment[];
}
/**
* Get deployment by ID
*/
export function getDeploymentById(deploymentId: number): Deployment | undefined {
const stmt = getDb().prepare("SELECT * FROM deployments WHERE id = ?");
return stmt.get(deploymentId) as Deployment | undefined;
}
/**
* Get commands for a deployment
*/
export function getCommandsByDeploymentId(deploymentId: number): Command[] {
const stmt = getDb().prepare("SELECT * FROM commands WHERE deployment_id = ? ORDER BY id ASC");
return stmt.all(deploymentId) as Command[];
}

379
index.ts Normal file
View File

@ -0,0 +1,379 @@
import { Hono } from "hono";
import type { Context } from "hono";
import {
initializeDatabase,
createDeployment,
updateDeploymentStatus,
getDeployments,
getDeploymentById,
getCommandsByDeploymentId,
type Deployment,
type Command,
} from "./db";
import {
loadEnvConfig,
verifySignature,
execCommand,
gitPullWithAuth,
type EnvConfig,
} from "./utils";
import { basename } from "node:path";
interface GiteaWebhookPayload {
repository?: {
name?: string;
owner?: {
login?: string;
};
full_name?: string;
};
ref?: string;
commits?: Array<{
id?: string;
message?: string;
}>;
}
// Deploy function signature
type DeployFunction = (
branch?: string,
commitHash?: string,
existingDeploymentId?: number
) => Promise<{ success: boolean; message: string; deploymentId?: number }>;
// Repository configuration - maps repo to its deploy function
interface RepoEntry {
repoName: string;
organization: string;
deploy: DeployFunction;
}
// Load environment configuration at startup
const env: EnvConfig = loadEnvConfig();
// Initialize database
initializeDatabase(process.env.DB_PATH);
// ============================================================================
// Deploy Functions for Each Repository
// ============================================================================
interface RepoMetadata {
repoPath: string;
organization: string;
repoName: string;
}
function resolveRepoMetadata(localPath: string, label: string): RepoMetadata {
const proc = Bun.spawnSync(["git", "config", "--get", "remote.origin.url"], {
cwd: localPath,
stdout: "pipe",
stderr: "pipe",
});
const decoder = new TextDecoder();
const remoteUrl = decoder.decode(proc.stdout).trim();
if (proc.exitCode !== 0 || !remoteUrl) {
console.error(`❌ Unable to resolve remote.origin.url for ${label} at ${localPath}`);
console.error(decoder.decode(proc.stderr).trim() || "No git remote configured");
process.exit(1);
}
const repoPath = normalizeRepoPath(remoteUrl);
const { organization, repoName } = extractOrganizationAndRepo(repoPath, localPath);
return { repoPath, organization, repoName };
}
function normalizeRepoPath(remoteUrl: string): string {
if (remoteUrl.startsWith("http://") || remoteUrl.startsWith("https://")) {
const url = new URL(remoteUrl);
return `${url.host}${url.pathname}`.replace(/^\/+/, "");
}
const sshMatch = remoteUrl.match(/^(?:.+@)?([^:]+):(.+)$/);
if (sshMatch) {
return `${sshMatch[1]}/${sshMatch[2]}`.replace(/^\/+/, "");
}
return remoteUrl.replace(/^\/+/, "");
}
function extractOrganizationAndRepo(repoPath: string, localPath: string): { organization: string; repoName: string } {
const cleaned = repoPath.replace(/\.git$/, "");
const parts = cleaned.split("/").filter(Boolean);
const organization = parts.at(-2);
const repoName = parts.at(-1);
if (organization && repoName) {
return { organization, repoName };
}
return { organization: "", repoName: basename(localPath) };
}
function createPullOnlyDeploy(
label: string,
repoPath: string,
localPath: string
): DeployFunction {
return async (
branch?: string,
commitHash?: string,
existingDeploymentId?: number
): Promise<{ success: boolean; message: string; deploymentId?: number }> => {
const repository = repoPath.replace(/\.git$/, "");
const deploymentId = existingDeploymentId ?? createDeployment(repository, branch, commitHash, "in_progress");
if (existingDeploymentId) updateDeploymentStatus(deploymentId, "in_progress");
console.log(`Starting deployment for ${label} (Deployment ID: ${deploymentId})...`);
try {
const gitResult = await gitPullWithAuth(
repoPath,
localPath,
deploymentId,
env.productionBranch,
env.giteaUsername,
env.giteaPassword
);
if (!gitResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: gitResult.error!, deploymentId };
}
console.log(`✅ Deployment successful`);
updateDeploymentStatus(deploymentId, "success");
return { success: true, message: `Successfully updated ${label}`, deploymentId };
} catch (error) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `Deployment failed: ${error instanceof Error ? error.message : String(error)}`, deploymentId };
}
};
}
// ============================================================================
// Repository Configuration
// ============================================================================
const adminRepo = resolveRepoMetadata(env.yimaruAdminPath, "Yimaru Admin");
const backendRepo = resolveRepoMetadata(env.yimaruBackendPath, "Yimaru Backend");
const REPO_CONFIGS: RepoEntry[] = [
{
repoName: adminRepo.repoName,
organization: adminRepo.organization,
deploy: createPullOnlyDeploy("Yimaru Admin", adminRepo.repoPath, env.yimaruAdminPath),
},
{
repoName: backendRepo.repoName,
organization: backendRepo.organization,
deploy: createPullOnlyDeploy("Yimaru Backend", backendRepo.repoPath, env.yimaruBackendPath),
},
];
/**
* Find repository entry by name
*/
function findRepoEntry(repoName: string, organization: string): RepoEntry | undefined {
return REPO_CONFIGS.find(
(entry) =>
entry.repoName === repoName &&
(entry.organization.length === 0 || entry.organization === organization)
);
}
// ============================================================================
// HTTP Handlers
// ============================================================================
/**
* Handle webhook request
*/
async function handleWebhook(c: Context): Promise<Response> {
try {
// Get signature and auth header from headers
const signature = c.req.header("X-Gitea-Signature") || "";
const authHeader = c.req.header("Authorization") || c.req.header("X-Gitea-Auth") || "";
const payload = await c.req.text();
// Verify auth header
if (authHeader !== env.webhookAuthHeader) {
console.error("Invalid webhook auth header");
return c.json({ error: "Invalid authorization" }, 401);
}
// Verify signature
if (!verifySignature(payload, signature, env.webhookSecret)) {
console.error("Invalid webhook signature");
return c.json({ error: "Invalid signature" }, 401);
}
// Parse payload
const data: GiteaWebhookPayload = JSON.parse(payload);
// Extract repository information
const repoName = data.repository?.name;
const organization = data.repository?.owner?.login || data.repository?.full_name?.split("/")[0];
const ref = data.ref || "";
const commitHash = data.commits?.[0]?.id || "";
if (!repoName || !organization) {
return c.json({ error: "Invalid webhook payload: missing repository information" }, 400);
}
// Extract branch name from ref (e.g., "refs/heads/main" -> "main")
const branch = ref.replace("refs/heads/", "") || undefined;
// Only process pushes to the production branch
if (ref !== `refs/heads/${env.productionBranch}`) {
console.log(`⚠️ Ignoring push to non-production branch: ${ref}`);
return c.json({
message: `Ignoring push to ${ref}. Only the production branch (${env.productionBranch}) triggers deployments.`,
}, 200);
}
console.log(`Received webhook for ${organization}/${repoName} on ${ref} (commit: ${commitHash})`);
// Find repository entry
const entry = findRepoEntry(repoName, organization);
if (!entry) {
console.warn(`No configuration found for ${organization}/${repoName}`);
return c.json({ message: `Repository ${organization}/${repoName} not configured` }, 200);
}
// Create deployment record immediately
const deploymentId = createDeployment(
`${entry.organization}/${entry.repoName}`,
branch,
commitHash,
"pending"
);
// Run deployment in background (don't await)
entry.deploy(branch, commitHash, deploymentId)
.then((result) => {
if (result.success) {
console.log(`✅ Deployment ${deploymentId} completed: ${result.message}`);
} else {
console.error(`❌ Deployment ${deploymentId} failed: ${result.message}`);
}
})
.catch((error) => {
console.error(`❌ Deployment ${deploymentId} error:`, error);
updateDeploymentStatus(deploymentId, "failed");
});
// Immediately respond to webhook
return c.json({
message: "Deployment started",
deploymentId,
repository: `${entry.organization}/${entry.repoName}`,
branch,
}, 202);
} catch (error) {
console.error("Error processing webhook:", error);
return c.json({
error: "Internal server error",
message: error instanceof Error ? error.message : String(error),
}, 500);
}
}
function handleHealthCheck(c: Context): Response {
return c.json({
status: "ok",
timestamp: new Date().toISOString(),
repositories: REPO_CONFIGS.map((entry) => ({
repo: `${entry.organization}/${entry.repoName}`,
})),
});
}
function handleGetDeployments(c: Context): Response {
const limit = parseInt(c.req.query("limit") || "50", 10);
const repository = c.req.query("repository");
const deployments = getDeployments(limit, repository);
return c.json({ deployments });
}
function handleGetDeployment(c: Context): Response {
const deploymentId = parseInt(c.req.param("id"), 10);
if (isNaN(deploymentId)) {
return c.json({ error: "Invalid deployment ID" }, 400);
}
const deployment = getDeploymentById(deploymentId);
if (!deployment) {
return c.json({ error: "Deployment not found" }, 404);
}
const commands = getCommandsByDeploymentId(deploymentId);
return c.json({ deployment, commands });
}
// ============================================================================
// Hono App Setup
// ============================================================================
const app = new Hono();
// HTML Pages
app.get("/", async (c: Context) => {
const limit = parseInt(c.req.query("limit") || "50", 10);
const repository = c.req.query("repository");
const deployments = getDeployments(limit, repository);
const { DeploymentsList } = await import("./pages/DeploymentsList");
const html = DeploymentsList({ deployments });
return c.html(html);
});
app.get("/deployments/:id", async (c: Context) => {
const deploymentId = parseInt(c.req.param("id"), 10);
if (isNaN(deploymentId)) {
return c.html("<html><body><h1>Invalid deployment ID</h1></body></html>", 400);
}
const deployment = getDeploymentById(deploymentId);
if (!deployment) {
return c.html("<html><body><h1>Deployment not found</h1></body></html>", 404);
}
const commands = getCommandsByDeploymentId(deploymentId);
const { DeploymentDetail } = await import("./pages/DeploymentDetail");
const html = DeploymentDetail({ deployment, commands });
return c.html(html);
});
// Health check endpoint
app.get("/health", handleHealthCheck);
// API endpoints (JSON)
app.get("/api/deployments", handleGetDeployments);
app.get("/api/deployments/:id", handleGetDeployment);
// Webhook endpoints
app.post("/webhook", handleWebhook);
app.post("/webhook/gitea", handleWebhook);
// 404 handler
app.notFound((c: Context) => {
return c.json({ error: "Not found" }, 404);
});
// Start server
const server = Bun.serve({
port: env.port,
fetch: app.fetch,
});
console.log(`🚀 Gitea Webhook CD Server running on http://localhost:${server.port}`);
console.log(`📡 Webhook endpoint: http://localhost:${server.port}/webhook`);
console.log(`❤️ Health check: http://localhost:${server.port}/health`);
console.log(`📊 Deployments API: http://localhost:${server.port}/deployments`);
console.log(`💾 Database: ${process.env.DB_PATH || "deployments.db"}`);

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "yimaru-cicd",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"hono": "^4.11.7"
}
}

104
pages/DeploymentDetail.ts Normal file
View File

@ -0,0 +1,104 @@
import { Layout } from "../components/Layout";
import type { Deployment, Command } from "../index";
interface DeploymentDetailProps {
deployment: Deployment;
commands: Command[];
}
function getStatusBadge(status: string): string {
const statusClass = status === "success" ? "badge-success" :
status === "failed" ? "badge-failed" :
status === "pending" ? "badge-pending" :
"badge-in_progress";
return `<span class="badge ${statusClass}">${status}</span>`;
}
function formatDate(dateString: string | undefined): string {
if (!dateString) return "N/A";
const date = new Date(dateString);
return date.toLocaleString();
}
function escapeHtml(text: string | undefined): string {
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
export function DeploymentDetail({ deployment, commands }: DeploymentDetailProps): string {
const content = `
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px;">
<h2 style="margin: 0;">Deployment #${deployment.id}</h2>
<a href="/" class="btn btn-secondary"> Back to List</a>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px; margin-bottom: 30px;">
<div>
<strong>Repository:</strong>
<p>${escapeHtml(deployment.repository)}</p>
</div>
<div>
<strong>Branch:</strong>
<p>${escapeHtml(deployment.branch) || "N/A"}</p>
</div>
<div>
<strong>Commit:</strong>
<p>
${deployment.commit_hash ?
`<code style="font-size: 12px;">${escapeHtml(deployment.commit_hash)}</code>` :
"N/A"}
</p>
</div>
<div>
<strong>Status:</strong>
<p>${getStatusBadge(deployment.status)}</p>
</div>
<div>
<strong>Created At:</strong>
<p class="timestamp">${formatDate(deployment.created_at)}</p>
</div>
</div>
<h3 style="margin-bottom: 15px;">Commands (${commands.length})</h3>
${commands.length === 0 ?
"<p>No commands executed.</p>" :
commands.map((command) => `
<div class="command">
<div class="command-header">
<strong>${escapeHtml(command.command)}</strong>
<div>
${command.success ?
'<span class="badge badge-success">Success</span>' :
'<span class="badge badge-failed">Failed</span>'}
${command.exit_code !== null ?
`<span style="margin-left: 10px; color: #999; font-size: 12px;">Exit: ${command.exit_code}</span>` :
""}
</div>
</div>
${command.stdout ? `
<div class="command-output">
<strong>Output:</strong>
<pre>${escapeHtml(command.stdout)}</pre>
</div>
` : ""}
${command.stderr ? `
<div class="command-error">
<strong>Error:</strong>
<pre>${escapeHtml(command.stderr)}</pre>
</div>
` : ""}
</div>
`).join("")}
</div>
`;
return Layout({ title: `Deployment #${deployment.id}`, children: content });
}

77
pages/DeploymentsList.ts Normal file
View File

@ -0,0 +1,77 @@
import { Layout } from "../components/Layout";
import type { Deployment } from "../index";
interface DeploymentsListProps {
deployments: Deployment[];
}
function getStatusBadge(status: string): string {
const statusClass = status === "success" ? "badge-success" :
status === "failed" ? "badge-failed" :
status === "pending" ? "badge-pending" :
"badge-in_progress";
return `<span class="badge ${statusClass}">${status}</span>`;
}
function formatDate(dateString: string | undefined): string {
if (!dateString) return "N/A";
const date = new Date(dateString);
return date.toLocaleString();
}
function escapeHtml(text: string | undefined): string {
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
export function DeploymentsList({ deployments }: DeploymentsListProps): string {
const content = `
<div class="card">
<h2>Deployment History</h2>
${deployments.length === 0 ?
"<p>No deployments yet.</p>" :
`<table>
<thead>
<tr>
<th>ID</th>
<th>Repository</th>
<th>Branch</th>
<th>Commit</th>
<th>Status</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${deployments.map((deployment) => `
<tr>
<td>${deployment.id}</td>
<td>${escapeHtml(deployment.repository)}</td>
<td>${escapeHtml(deployment.branch) || "N/A"}</td>
<td>
${deployment.commit_hash ?
`<code style="font-size: 11px;">${escapeHtml(deployment.commit_hash.substring(0, 7))}</code>` :
"N/A"}
</td>
<td>${getStatusBadge(deployment.status)}</td>
<td>
<span class="timestamp">${formatDate(deployment.created_at)}</span>
</td>
<td>
<a href="/deployments/${deployment.id}" class="btn btn-primary" style="font-size: 12px; padding: 6px 12px;">View</a>
</td>
</tr>
`).join("")}
</tbody>
</table>`}
</div>
`;
return Layout({ title: "Deployments", children: content });
}

29
tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

259
utils.ts Normal file
View File

@ -0,0 +1,259 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { insertCommand } from "./db";
export interface EnvConfig {
webhookSecret: string;
webhookAuthHeader: string;
giteaUsername: string;
giteaPassword: string;
yimaruAdminPath: string;
yimaruBackendPath: string;
port: number;
dbPath?: string;
productionBranch: string;
}
/**
* Load and validate environment variables
* Exits the process if any required variable is missing
*/
export function loadEnvConfig(): EnvConfig {
const requiredVars = [
"GITEA_WEBHOOK_SECRET",
"GITEA_WEBHOOK_AUTH_HEADER",
"GITEA_USERNAME",
"GITEA_PASSWORD",
"YIMARU_ADMIN_PATH",
"YIMARU_BACKEND_PATH",
];
const missing: string[] = [];
for (const varName of requiredVars) {
if (!process.env[varName]) {
missing.push(varName);
}
}
if (missing.length > 0) {
console.error("❌ Missing required environment variables:");
missing.forEach((varName) => console.error(` - ${varName}`));
console.error("\nPlease set all required environment variables in your .env file.");
process.exit(1);
}
const port = parseInt(process.env.PORT || "", 10);
if (isNaN(port) || port < 1 || port > 65535) {
console.error(`❌ Invalid PORT value: ${process.env.PORT}`);
process.exit(1);
}
// Parse production branch from environment variable
const productionBranchRaw = process.env.PRODUCTION_BRANCH;
const productionBranch = typeof productionBranchRaw === "string" ? productionBranchRaw.trim() : "";
if (!productionBranch || productionBranch.length === 0) {
console.error("❌ PRODUCTION_BRANCH must contain a valid branch name");
process.exit(1);
}
console.log(`✅ Production branch configured: ${productionBranch}`);
return {
webhookSecret: process.env.GITEA_WEBHOOK_SECRET!,
webhookAuthHeader: process.env.GITEA_WEBHOOK_AUTH_HEADER!,
giteaUsername: process.env.GITEA_USERNAME!,
giteaPassword: process.env.GITEA_PASSWORD!,
yimaruAdminPath: process.env.YIMARU_ADMIN_PATH!,
yimaruBackendPath: process.env.YIMARU_BACKEND_PATH!,
port,
productionBranch,
};
}
/**
* Verify Gitea webhook signature
*/
export function verifySignature(payload: string, signature: string, webhookSecret: string): boolean {
const hmac = createHmac("sha256", webhookSecret);
hmac.update(payload);
const expectedHex = hmac.digest("hex");
const expectedSignature = `sha256=${expectedHex}`;
// Normalize the signature - Gitea may send with or without "sha256=" prefix
const normalizedSignature = signature.startsWith("sha256=")
? signature
: `sha256=${signature}`;
// Ensure both buffers have the same length before comparing
const signatureBuffer = Buffer.from(normalizedSignature);
const expectedBuffer = Buffer.from(expectedSignature);
if (signatureBuffer.length !== expectedBuffer.length) {
console.error(`Signature length mismatch: received ${signatureBuffer.length}, expected ${expectedBuffer.length}`);
return false;
}
return timingSafeEqual(signatureBuffer, expectedBuffer);
}
/**
* Execute shell command and return result
*/
export async function execCommand(
command: string,
cwd?: string,
deploymentId?: number
): Promise<{ success: boolean; output: string; error?: string; exitCode?: number }> {
try {
// Build PATH that includes node_modules/.bin for local binaries (like tsc)
const currentPath = process.env.PATH || "/usr/local/bin:/usr/bin:/bin";
const nodeModulesBin = cwd ? `${cwd}/node_modules/.bin` : "";
const enhancedPath = nodeModulesBin ? `${nodeModulesBin}:${currentPath}` : currentPath;
// Use shell to properly handle quoted arguments and special characters
const proc = Bun.spawn(["/bin/sh", "-c", command], {
cwd,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
PATH: enhancedPath,
},
});
const output = await new Response(proc.stdout).text();
const error = await new Response(proc.stderr).text();
await proc.exited;
const exitCode = proc.exitCode || 0;
const success = exitCode === 0;
// Store command in database if deploymentId is provided
if (deploymentId !== undefined) {
insertCommand(deploymentId, command, output, error || null, exitCode, success);
}
if (!success) {
return {
success: false,
output,
error: error || `Command failed with exit code ${exitCode}`,
exitCode,
};
}
return {
success: true,
output,
exitCode,
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
// Store command in database even on exception
if (deploymentId !== undefined) {
insertCommand(deploymentId, command, "", errorMessage, -1, false);
}
return {
success: false,
output: "",
error: errorMessage,
exitCode: -1,
};
}
}
/**
* Build authenticated git URL
*/
export function buildAuthenticatedUrl(
repoPath: string,
username: string,
password: string
): { url: string; redacted: string } {
const escapedUsername = encodeURIComponent(username);
const escapedPassword = encodeURIComponent(password);
const url = `https://${escapedUsername}:${escapedPassword}@${repoPath}`;
const redacted = `https://***:***@${repoPath}`;
return { url, redacted };
}
/**
* Git pull with authentication (for PM2 services with writable .git)
*/
export async function gitPullWithAuth(
repoPath: string,
localPath: string,
deploymentId: number,
productionBranch: string,
username: string,
password: string
): Promise<{ success: boolean; error?: string }> {
const { url, redacted } = buildAuthenticatedUrl(repoPath, username, password);
// Stash local changes
console.log(`Stashing local changes...`);
await execCommand(`git stash save "local changes ${new Date().toISOString()}"`, localPath, deploymentId);
// Reset to HEAD
console.log(`Resetting to HEAD...`);
const resetResult = await execCommand("git reset --hard HEAD", localPath, deploymentId);
if (!resetResult.success) {
return { success: false, error: `Git reset failed: ${resetResult.error || resetResult.output}` };
}
// Fetch
console.log(`Fetching from remote...`);
const fetchProc = Bun.spawn(
["git", "fetch", url, productionBranch],
{ cwd: localPath, stdout: "pipe", stderr: "pipe" }
);
const fetchOutput = await new Response(fetchProc.stdout).text();
const fetchError = await new Response(fetchProc.stderr).text();
await fetchProc.exited;
insertCommand(
deploymentId,
`git fetch ${redacted} ${productionBranch}`,
fetchOutput,
fetchError || null,
fetchProc.exitCode || 0,
fetchProc.exitCode === 0
);
if (fetchProc.exitCode !== 0) {
return { success: false, error: `Git fetch failed: ${fetchError || fetchOutput}` };
}
// Checkout branch
await execCommand(`git checkout ${productionBranch}`, localPath, deploymentId);
// Pull
console.log(`Pulling latest changes...`);
const pullProc = Bun.spawn(
["git", "pull", url, productionBranch],
{ cwd: localPath, stdout: "pipe", stderr: "pipe" }
);
const pullOutput = await new Response(pullProc.stdout).text();
const pullError = await new Response(pullProc.stderr).text();
await pullProc.exited;
insertCommand(
deploymentId,
`git pull ${redacted} ${productionBranch}`,
pullOutput,
pullError || null,
pullProc.exitCode || 0,
pullProc.exitCode === 0
);
if (pullProc.exitCode !== 0) {
return { success: false, error: `Git pull failed: ${pullError || pullOutput}` };
}
console.log(`Git pull successful`);
return { success: true };
}

32
yimaru-cd.service Normal file
View File

@ -0,0 +1,32 @@
[Unit]
Description=Yimaru CI/CD Webhook Server
After=network.target
[Service]
Type=simple
User=yimaru
Group=yimaru
WorkingDirectory=/home/yimaru/yimaru_services/yimaru_cicd
Environment="NODE_ENV=production"
Environment="PATH=/home/yimaru/.bun/bin:/home/yimaru/.nvm/versions/node/v20/bin:/home/yimaru/.local/bin:/usr/local/bin:/usr/bin:/bin"
EnvironmentFile=/home/yimaru/yimaru_services/yimaru_cicd/.env
ExecStart=/home/yimaru/.bun/bin/bun dist/index.js
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=yimaru-cd
# Security settings
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/yimaru/yimaru_services/yimaru_cicd /home/yimaru/.npm /var/www/html
# Resource limits
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target