-
This commit is contained in:
commit
25f1a76803
111
.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc
Normal file
111
.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc
Normal 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
34
.gitignore
vendored
Normal 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
35
README.md
Normal 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
31
bun.lock
Normal 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
268
components/Layout.ts
Normal 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
148
db.ts
Normal 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
379
index.ts
Normal 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
15
package.json
Normal 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
104
pages/DeploymentDetail.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
77
pages/DeploymentsList.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
29
tsconfig.json
Normal 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
259
utils.ts
Normal 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
32
yimaru-cd.service
Normal 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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user