feat: introduce Gatekeeper application, Directus utilities, and monorepo configuration for linting, testing, and husky hooks.
Some checks failed
Code Quality / lint-and-build (push) Failing after 52s
Release Packages / release (push) Failing after 32s

This commit is contained in:
2026-02-01 21:23:34 +01:00
parent c2a0ba88c0
commit 83b4ea8807
51 changed files with 3150 additions and 282 deletions

View File

@@ -32,5 +32,8 @@ jobs:
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test
- name: Build
run: pnpm build

1
.husky/commit-msg Normal file
View File

@@ -0,0 +1 @@
npx --no -- commitlint --edit "$1"

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx lint-staged

1
.lintstagedrc.js Normal file
View File

@@ -0,0 +1 @@
export { default } from "@mintel/husky-config/lint-staged";

View File

@@ -7,7 +7,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test": "vitest run"
},
"dependencies": {
"next": "15.1.6",

View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from "vitest";
describe("sample-website", () => {
it("should pass basic health check", () => {
expect(true).toBe(true);
});
});

1
commitlint.config.js Normal file
View File

@@ -0,0 +1 @@
export { default } from "@mintel/husky-config/commitlint";

3
eslint.config.js Normal file
View File

@@ -0,0 +1,3 @@
import { nextConfig } from "@mintel/eslint-config/next";
export default nextConfig;

View File

@@ -1,18 +1,39 @@
{
"name": "@mintel/monorepo",
"private": true,
"type": "module",
"scripts": {
"build": "pnpm --filter \"./packages/*\" build",
"dev": "pnpm --filter \"./packages/*\" dev",
"lint": "pnpm --filter \"./packages/*\" lint",
"test": "pnpm --filter \"./packages/*\" test",
"build": "pnpm -r build",
"dev": "pnpm -r dev",
"lint": "pnpm -r lint",
"test": "pnpm -r test",
"changeset": "changeset",
"version-packages": "changeset version",
"release": "pnpm build && changeset publish"
"release": "pnpm build && changeset publish",
"prepare": "husky"
},
"devDependencies": {
"@changesets/cli": "^2.29.8",
"prettier": "^3.0.0",
"typescript": "^5.0.0"
"@commitlint/cli": "^20.4.0",
"@commitlint/config-conventional": "^20.4.0",
"@mintel/eslint-config": "workspace:*",
"@mintel/husky-config": "workspace:*",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"eslint-plugin-next": "^0.0.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"happy-dom": "^20.4.0",
"husky": "^9.1.7",
"jsdom": "^27.4.0",
"lint-staged": "^16.2.7",
"prettier": "^3.8.1",
"typescript": "^5.0.0",
"typescript-eslint": "^8.54.0",
"vitest": "^4.0.18"
}
}

View File

@@ -12,7 +12,8 @@
"scripts": {
"build": "tsup src/index.ts --format esm --target es2020",
"start": "node dist/index.js",
"dev": "tsup src/index.ts --format esm --watch --target es2020"
"dev": "tsup src/index.ts --format esm --watch --target es2020",
"test": "vitest run"
},
"dependencies": {
"commander": "^11.0.0",

View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from "vitest";
describe("cli", () => {
it("should have a working environment", () => {
expect(true).toBe(true);
});
});

View File

@@ -44,6 +44,7 @@ program
react: "^19.0.0",
"react-dom": "^19.0.0",
"@mintel/next-utils": "workspace:*",
"@directus/sdk": "^21.0.0",
},
devDependencies: {
"@types/node": "^20.0.0",
@@ -53,6 +54,7 @@ program
"@mintel/tsconfig": "workspace:*",
"@mintel/eslint-config": "workspace:*",
"@mintel/next-config": "workspace:*",
"@mintel/husky-config": "workspace:*",
},
};
await fs.writeJson(path.join(fullPath, "package.json"), pkgJson, {
@@ -96,7 +98,17 @@ export default nextConfig;
`;
await fs.writeFile(
path.join(fullPath, "eslint.config.mjs"),
eslintConfig
eslintConfig,
);
// Create Husky/Linting configs
await fs.writeFile(
path.join(fullPath, "commitlint.config.js"),
`export { default } from "@mintel/husky-config/commitlint";\n`,
);
await fs.writeFile(
path.join(fullPath, ".lintstagedrc.js"),
`export { default } from "@mintel/husky-config/lint-staged";\n`,
);
// Create env validation script
@@ -111,7 +123,7 @@ try {
} catch (error) {
process.exit(1);
}
`
`,
);
// Create basic src structure
@@ -129,7 +141,7 @@ export default createMintelMiddleware({
export const config = {
matcher: ["/((?!api|_next|_vercel|health|.*\\\\..*).*)", "/", "/(de|en)/:path*"]
};
`
`,
);
// Create i18n/request.ts
@@ -143,21 +155,29 @@ export default createMintelI18nRequestConfig(
"en",
(locale) => import(\`../../messages/\${locale}.json\`)
);
`
`,
);
// Create messages directory
await fs.ensureDir(path.join(fullPath, "messages"));
await fs.writeJson(path.join(fullPath, "messages/en.json"), {
await fs.writeJson(
path.join(fullPath, "messages/en.json"),
{
Index: {
title: "Welcome"
}
}, { spaces: 2 });
await fs.writeJson(path.join(fullPath, "messages/de.json"), {
title: "Welcome",
},
},
{ spaces: 2 },
);
await fs.writeJson(
path.join(fullPath, "messages/de.json"),
{
Index: {
title: "Willkommen"
}
}, { spaces: 2 });
title: "Willkommen",
},
},
{ spaces: 2 },
);
// Create instrumentation.ts
await fs.writeFile(
@@ -171,7 +191,7 @@ export async function register() {
}
export const onRequestError = Sentry.captureRequestError;
`
`,
);
await fs.writeFile(
@@ -192,11 +212,11 @@ export default function RootLayout({
}) {
return (
<html lang={locale}>
<body>{children}</body>
<body className="antialiased">{children}</body>
</html>
);
}
`
`,
);
await fs.writeFile(
@@ -212,7 +232,7 @@ export default function Home() {
</main>
);
}
`
`,
);
// Copy infra templates
@@ -220,21 +240,75 @@ export default function Home() {
if (await fs.pathExists(infraPath)) {
await fs.copy(
path.join(infraPath, "docker/Dockerfile.nextjs"),
path.join(fullPath, "Dockerfile")
path.join(fullPath, "Dockerfile"),
);
await fs.copy(
path.join(infraPath, "docker/docker-compose.template.yml"),
path.join(fullPath, "docker-compose.yml")
path.join(fullPath, "docker-compose.yml"),
);
await fs.ensureDir(path.join(fullPath, ".gitea/workflows"));
await fs.copy(
path.join(infraPath, "gitea/deploy-action.yml"),
path.join(fullPath, ".gitea/workflows/deploy.yml")
path.join(fullPath, ".gitea/workflows/deploy.yml"),
);
// Create Directus structure
await fs.ensureDir(path.join(fullPath, "directus/uploads"));
await fs.ensureDir(path.join(fullPath, "directus/extensions"));
await fs.writeFile(
path.join(fullPath, "directus/uploads/.gitkeep"),
"",
);
await fs.writeFile(
path.join(fullPath, "directus/extensions/.gitkeep"),
"",
);
// Create .env.example
const envExample = `# Project
PROJECT_NAME=${projectName}
PROJECT_COLOR=#82ed20
# Authentication
GATEKEEPER_PASSWORD=mintel
AUTH_COOKIE_NAME=mintel_gatekeeper_session
# Host Config (Local)
TRAEFIK_HOST=\`${projectName}.localhost\`
DIRECTUS_HOST=\`cms.${projectName}.localhost\`
# Next.js
NEXT_PUBLIC_BASE_URL=http://${projectName}.localhost
# Directus
DIRECTUS_URL=http://cms.${projectName}.localhost
DIRECTUS_KEY=$(openssl rand -hex 32 2>/dev/null || echo "mintel-key")
DIRECTUS_SECRET=$(openssl rand -hex 32 2>/dev/null || echo "mintel-secret")
DIRECTUS_ADMIN_EMAIL=admin@mintel.me
DIRECTUS_ADMIN_PASSWORD=mintel-admin-pass
DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus
DIRECTUS_DB_PASSWORD=mintel-db-pass
# Sentry / Glitchtip
SENTRY_DSN=
# Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
`;
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
// Copy premium templates (globals.css, lib/directus.ts, scripts/setup-directus.ts)
const templatePath = path.join(infraPath, "templates/website");
if (await fs.pathExists(templatePath)) {
console.log(chalk.blue("Applying premium templates..."));
await fs.copy(templatePath, fullPath, { overwrite: true });
}
}
console.log(
chalk.green(`Successfully initialized ${projectName} at ${fullPath}`)
chalk.green(`Successfully initialized ${projectName} at ${fullPath}`),
);
console.log(chalk.yellow("\nNext steps:"));
console.log(chalk.cyan("1. pnpm install"));

3
packages/eslint-config/index.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
export const baseConfig: any;
declare const config: any;
export default config;

View File

@@ -0,0 +1,14 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
"no-unused-vars": "warn",
"no-console": "off",
"@typescript-eslint/no-explicit-any": "off",
},
},
);

3
packages/eslint-config/next.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
export const nextConfig: any;
declare const config: any;
export default config;

View File

@@ -8,11 +8,19 @@
"type": "module",
"main": "index.js",
"exports": {
".": "./index.js",
"./next": "./next.js"
".": {
"types": "./index.d.ts",
"default": "./index.js"
},
"./next": {
"types": "./next.d.ts",
"default": "./next.js"
}
},
"dependencies": {
"@eslint/eslintrc": "^3.0.0",
"eslint-config-next": "15.1.6"
"@eslint/js": "^9.39.2",
"eslint-config-next": "15.1.6",
"typescript-eslint": "^8.54.0"
}
}

View File

@@ -0,0 +1,8 @@
import mintelNextConfig from "@mintel/next-config";
import { NextConfig } from "next";
const nextConfig: NextConfig = {
// Gatekeeper specific overrides
};
export default mintelNextConfig(nextConfig);

View File

@@ -0,0 +1,33 @@
{
"name": "@mintel/gatekeeper",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest run"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^0.474.0",
"next": "15.1.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/next-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(req: NextRequest) {
const cookieStore = await cookies();
const authCookieName =
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
const password = process.env.GATEKEEPER_PASSWORD || "mintel";
const session = cookieStore.get(authCookieName);
if (session?.value === password) {
return new NextResponse("OK", { status: 200 });
}
// Traefik ForwardAuth headers
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
const host =
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
const proto = req.headers.get("x-forwarded-proto") || "https";
const loginUrl = `${proto}://${host}/gatekeeper/login?redirect=${encodeURIComponent(originalUrl)}`;
return NextResponse.redirect(loginUrl);
}

View File

@@ -0,0 +1,131 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { Lock, ShieldCheck, ArrowRight } from "lucide-react";
interface LoginPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function LoginPage({ searchParams }: LoginPageProps) {
const params = await searchParams;
const redirectUrl = (params.redirect as string) || "/";
const error = params.error === "1";
const projectName = process.env.PROJECT_NAME || "Mintel";
const projectColor = process.env.PROJECT_COLOR || "#82ed20";
async function login(formData: FormData) {
"use server";
const password = formData.get("password");
const expectedPassword = process.env.GATEKEEPER_PASSWORD || "mintel";
const authCookieName =
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
const targetRedirect = formData.get("redirect") as string;
if (password === expectedPassword) {
const cookieStore = await cookies();
cookieStore.set(authCookieName, expectedPassword, {
httpOnly: true,
secure: true,
path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days
sameSite: "lax",
});
redirect(targetRedirect);
} else {
redirect(
`/gatekeeper/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`,
);
}
}
return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-mintel-dark">
{/* Background Decor */}
<div className="absolute inset-0 bg-grid pointer-events-none opacity-20" />
<div
className="absolute inset-0 pointer-events-none opacity-30"
style={{
background: `radial-gradient(circle at center, ${projectColor}11 0%, transparent 70%)`,
}}
/>
<div className="relative z-10 w-full max-w-md px-6 animate-in fade-in zoom-in duration-700">
{/* Logo / Icon */}
<div className="flex justify-center mb-12">
<div
className="w-20 h-20 rounded-3xl flex items-center justify-center border border-white/10 bg-white/5 backdrop-blur-xl shadow-2xl"
style={{ borderBottom: `2px solid ${projectColor}44` }}
>
<ShieldCheck
className="w-10 h-10"
style={{ color: projectColor }}
/>
</div>
</div>
<div className="bg-white/[0.03] backdrop-blur-3xl border border-white/10 p-10 rounded-[48px] shadow-2xl relative overflow-hidden group">
{/* Subtle accent line */}
<div
className="absolute top-0 left-0 w-full h-1 opacity-50"
style={{
background: `linear-gradient(to right, transparent, ${projectColor}, transparent)`,
}}
/>
<div className="mb-10 text-center">
<h1 className="text-3xl font-black mb-3 tracking-tighter uppercase italic flex items-center justify-center gap-2">
{projectName.split(" ")[0]}
<span style={{ color: projectColor }}>GATEKEEPER</span>
</h1>
<p className="text-white/40 text-sm font-medium">
Restricted Infrastructure Access
</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-200 p-4 rounded-2xl mb-8 text-sm flex items-center gap-3 animate-pulse">
<Lock className="w-5 h-5 flex-shrink-0" />
<span>Invalid access password. Please try again.</span>
</div>
)}
<form action={login} className="space-y-8">
<input type="hidden" name="redirect" value={redirectUrl} />
<div className="space-y-3">
<label className="block text-[10px] font-black uppercase tracking-[0.3em] text-white/20 ml-5">
Authentication Code
</label>
<input
type="password"
name="password"
required
autoFocus
className="w-full bg-white/5 border border-white/10 rounded-3xl px-8 py-6 focus:outline-none focus:ring-2 transition-all text-xl tracking-[0.5em] text-center placeholder:tracking-normal placeholder:text-white/10"
style={{ "--tw-ring-color": `${projectColor}33` } as any}
placeholder="••••••••"
/>
</div>
<button
type="submit"
className="w-full font-black uppercase tracking-[0.2em] py-6 rounded-3xl transition-all active:scale-[0.98] flex items-center justify-center gap-3 shadow-lg hover:shadow-mintel-green/10"
style={{ backgroundColor: projectColor, color: "#000" }}
>
Verify Identity
<ArrowRight className="w-5 h-5" />
</button>
</form>
</div>
<div className="mt-12 text-center">
<p className="text-[10px] font-bold text-white/10 uppercase tracking-[0.5em]">
&copy; 2026 {projectName} Infrastructure
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #000c1f;
--foreground: #ffffff;
}
body {
color: var(--foreground);
background: var(--background);
min-height: 100vh;
}
.bg-grid {
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
}

View File

@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Gatekeeper | Access Control",
description: "Mintel Infrastructure Protection",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="antialiased">{children}</body>
</html>
);
}

View File

@@ -0,0 +1,18 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { Button } from "./Button";
import React from "react";
describe("Button", () => {
it("renders children correctly", () => {
render(<Button>Click me</Button>);
expect(screen.getByText("Click me")).toBeInTheDocument();
});
it("calls onClick when clicked", () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText("Click me"));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,18 @@
import React from "react";
export function Button({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) {
return (
<button
onClick={onClick}
className="px-4 py-2 bg-mintel-green text-mintel-blue rounded-xl font-bold"
>
{children}
</button>
);
}

View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom";

View File

@@ -0,0 +1,23 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
mintel: {
green: "#82ed20",
blue: "#001a4d",
dark: "#000c1f",
},
},
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,11 @@
{
"extends": "@mintel/tsconfig/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,17 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "happy-dom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

View File

@@ -0,0 +1,8 @@
export default {
extends: ["@commitlint/config-conventional"],
rules: {
"header-max-length": [2, "always", 150],
"subject-case": [0],
"subject-full-stop": [0],
},
};

View File

@@ -0,0 +1,2 @@
export { default as commitlint } from "./commitlint.js";
export { default as lintStaged } from "./lint-staged.js";

View File

@@ -0,0 +1,4 @@
export default {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,css,scss}": ["prettier --write"],
};

View File

@@ -0,0 +1,18 @@
{
"name": "@mintel/husky-config",
"version": "1.0.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
},
"type": "module",
"main": "index.js",
"exports": {
".": "./index.js",
"./commitlint": "./commitlint.js",
"./lint-staged": "./lint-staged.js"
},
"dependencies": {
"@commitlint/config-conventional": "^20.4.0"
}
}

View File

@@ -0,0 +1,47 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable pnpm && pnpm i --frozen-lockfile
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Build the application
RUN corepack enable pnpm && pnpm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
RUN apk add --no-cache curl
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -1,33 +1,109 @@
services:
app:
image: registry.infra.mintel.me/${PROJECT_NAME:-mintel/app}:${IMAGE_TAG:-latest}
image: registry.infra.mintel.me/mintel/${APP_NAME:-app}:${IMAGE_TAG:-latest}
restart: always
networks:
- infra
ports:
- "3000:3000"
env_file:
- ${ENV_FILE:-.env}
labels:
- "traefik.enable=true"
# HTTP ⇒ HTTPS redirect
- "traefik.http.routers.${APP_NAME:-app}-web.rule=Host(${TRAEFIK_HOST}) && !PathPrefix(`/.well-known/acme-challenge/`)"
- "traefik.http.routers.${APP_NAME:-app}-web.entrypoints=web"
- "traefik.http.routers.${APP_NAME:-app}-web.middlewares=redirect-https"
- "traefik.http.routers.${PROJECT_NAME:-app}-web.rule=Host(${TRAEFIK_HOST}) && !PathPrefix(`/.well-known/acme-challenge/`)"
- "traefik.http.routers.${PROJECT_NAME:-app}-web.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-app}-web.middlewares=redirect-https"
# HTTPS router
- "traefik.http.routers.${APP_NAME:-app}.rule=Host(${TRAEFIK_HOST})"
- "traefik.http.routers.${APP_NAME:-app}.entrypoints=websecure"
- "traefik.http.routers.${APP_NAME:-app}.tls.certresolver=le"
- "traefik.http.routers.${APP_NAME:-app}.tls=true"
- "traefik.http.routers.${APP_NAME:-app}.service=${APP_NAME:-app}"
- "traefik.http.services.${APP_NAME:-app}.loadbalancer.server.port=3000"
- "traefik.http.services.${APP_NAME:-app}.loadbalancer.server.scheme=http"
- "traefik.http.routers.${PROJECT_NAME:-app}.rule=Host(${TRAEFIK_HOST})"
- "traefik.http.routers.${PROJECT_NAME:-app}.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-app}.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-app}.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-app}.service=${PROJECT_NAME:-app}"
- "traefik.http.services.${PROJECT_NAME:-app}.loadbalancer.server.port=3000"
- "traefik.http.services.${PROJECT_NAME:-app}.loadbalancer.server.scheme=http"
# Forwarded Headers
- "traefik.http.middlewares.${APP_NAME:-app}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.${APP_NAME:-app}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Middlewares
- "traefik.http.routers.${APP_NAME:-app}.middlewares=${APP_NAME:-app}-forward,compress"
- "traefik.http.middlewares.${PROJECT_NAME:-app}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.${PROJECT_NAME:-app}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Middlewares (Auth + Compress)
- "traefik.http.routers.${PROJECT_NAME:-app}.middlewares=${PROJECT_NAME:-app}-forward,${AUTH_MIDDLEWARE:-compress}"
# Gatekeeper Router (to show the login page)
- "traefik.http.routers.${PROJECT_NAME:-app}-gatekeeper.rule=Host(${TRAEFIK_HOST}) && PathPrefix(`/gatekeeper`)"
- "traefik.http.routers.${PROJECT_NAME:-app}-gatekeeper.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-app}-gatekeeper.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-app}-gatekeeper.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-app}-gatekeeper.service=${PROJECT_NAME:-app}-gatekeeper"
# Auth Middleware Definition
- "traefik.http.middlewares.${PROJECT_NAME:-app}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME:-app}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME:-app}-auth.forwardauth.authResponseHeaders=X-Auth-User"
gatekeeper:
image: registry.infra.mintel.me/mintel/gatekeeper:${IMAGE_TAG:-latest}
container_name: ${PROJECT_NAME}-gatekeeper
restart: always
networks:
- infra
env_file:
- ${ENV_FILE:-.env}
environment:
PORT: 3000
PROJECT_NAME: ${PROJECT_NAME:-Mintel App}
PROJECT_COLOR: ${PROJECT_COLOR:-#82ed20}
labels:
- "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
directus:
image: directus/directus:11
restart: always
networks:
- infra
env_file:
- ${ENV_FILE:-.env}
environment:
KEY: ${DIRECTUS_KEY}
SECRET: ${DIRECTUS_SECRET}
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
DB_CLIENT: 'pg'
DB_HOST: 'directus-db'
DB_PORT: '5432'
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel}
WEBSOCKETS_ENABLED: 'true'
PUBLIC_URL: ${DIRECTUS_URL}
SENTRY_DSN: ${SENTRY_DSN}
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
labels:
- "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME}-directus.rule=Host(${DIRECTUS_HOST})"
- "traefik.http.routers.${PROJECT_NAME}-directus.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME}-directus.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME}-directus.tls=true"
- "traefik.http.routers.${PROJECT_NAME}-directus.middlewares=${PROJECT_NAME}-forward,${AUTH_MIDDLEWARE:-compress}"
- "traefik.http.services.${PROJECT_NAME}-directus.loadbalancer.server.port=8055"
directus-db:
image: postgres:15-alpine
restart: always
networks:
- infra
env_file:
- ${ENV_FILE:-.env}
environment:
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel}
volumes:
- directus-db-data:/var/lib/postgresql/data
networks:
infra:
external: true
volumes:
directus-db-data:

View File

@@ -6,64 +6,85 @@ on:
- main
tags:
- 'v*'
workflow_dispatch:
inputs:
skip_long_checks:
description: 'Skip tests? (true/false)'
required: false
default: 'false'
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
build-and-deploy:
# ──────────────────────────────────────────────────────────────────────────────
# JOB 1: Prepare & Determine Environment
# ──────────────────────────────────────────────────────────────────────────────
prepare:
name: 🔍 Prepare Environment
runs-on: docker
outputs:
target: ${{ steps.determine.outputs.target }}
image_tag: ${{ steps.determine.outputs.image_tag }}
env_file: ${{ steps.determine.outputs.env_file }}
traefik_host: ${{ steps.determine.outputs.traefik_host }}
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
directus_url: ${{ steps.determine.outputs.directus_url }}
directus_host: ${{ steps.determine.outputs.directus_host }}
project_name: ${{ steps.determine.outputs.project_name }}
is_prod: ${{ steps.determine.outputs.is_prod }}
gotify_title: ${{ steps.determine.outputs.gotify_title }}
gotify_priority: ${{ steps.determine.outputs.gotify_priority }}
short_sha: ${{ steps.determine.outputs.short_sha }}
commit_msg: ${{ steps.determine.outputs.commit_msg }}
steps:
# ──────────────────────────────────────────────────────────────────────────────
# Workflow Start & Basic Info
# ──────────────────────────────────────────────────────────────────────────────
- name: 📢 Workflow Start
run: |
echo "┌──────────────────────────────────────────────────────────────┐"
echo "│ 🚀 Deployment Workflow gestartet │"
echo "├──────────────────────────────────────────────────────────────┤"
echo "│ Repository: ${{ github.repository }} │"
echo "│ Ref: ${{ github.ref }} │"
echo "│ Ref-Name: ${{ github.ref_name }} │"
echo "│ Commit: ${{ github.sha }} │"
echo "│ Actor: ${{ github.actor }} │"
echo "│ Datum: $(date -u +'%Y-%m-%d %H:%M:%S UTC') │"
echo "└──────────────────────────────────────────────────────────────┘"
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# ──────────────────────────────────────────────────────────────────────────────
# Environment bestimmen + Commit-Message holen
# ──────────────────────────────────────────────────────────────────────────────
- name: 🔍 Environment & Version ermitteln
id: determine
run: |
TAG="${{ github.ref_name }}"
SHORT_SHA="${{ github.sha }}"
SHORT_SHA="${SHORT_SHA:0:9}"
# Get base domain from secret or env if possible, otherwise placeholder
# In a real project, you'd likely have a primary domain secret
DOMAIN_BASE=$(echo "${{ secrets.NEXT_PUBLIC_BASE_URL }}" | sed -E 's|https?://||' | sed -E 's|/.*||')
# Commit-Message holen (erste Zeile)
COMMIT_MSG=$(git log -1 --pretty=%s || echo "No commit message available")
# Base Domain (e.g. example.com)
DOMAIN_BASE=$(echo "${{ secrets.NEXT_PUBLIC_BASE_URL }}" | sed -E 's|https?://||' | sed -E 's|/.*||')
PRJ_ID="${{ github.event.repository.name }}"
if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then
if [[ "$COMMIT_MSG" =~ ^chore: ]]; then
TARGET="skip"
GOTIFY_TITLE=" Skip Deploy (Chore)"
GOTIFY_PRIORITY=2
else
TARGET="testing"
IMAGE_TAG="main-${SHORT_SHA}"
ENV_FILE=".env.testing"
TRAEFIK_HOST="\`testing.${DOMAIN_BASE}\`"
TRAEFIK_HOST="\`testing.\${DOMAIN_BASE}\`"
NEXT_PUBLIC_BASE_URL="https://testing.\${DOMAIN_BASE}"
DIRECTUS_URL="https://cms.testing.\${DOMAIN_BASE}"
DIRECTUS_HOST="\`cms.testing.\${DOMAIN_BASE}\`"
PROJECT_NAME="\${PRJ_ID}-testing"
IS_PROD="false"
GOTIFY_TITLE="🧪 Testing-Deploy"
GOTIFY_PRIORITY=4
fi
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
TARGET="production"
IMAGE_TAG="$TAG"
ENV_FILE=".env.prod"
TRAEFIK_HOST="\`${DOMAIN_BASE}\`, \`www.${DOMAIN_BASE}\`"
TRAEFIK_HOST="\${DOMAIN_BASE}, www.\${DOMAIN_BASE}" # Note: Host() backticks usually needed in compose
TRAEFIK_HOST="\`\${DOMAIN_BASE}\`, \`www.\${DOMAIN_BASE}\`"
NEXT_PUBLIC_BASE_URL="https://\${DOMAIN_BASE}"
DIRECTUS_URL="https://cms.\${DOMAIN_BASE}"
DIRECTUS_HOST="\`cms.\${DOMAIN_BASE}\`"
PROJECT_NAME="\${PRJ_ID}-prod"
IS_PROD="true"
GOTIFY_TITLE="🚀 Production-Release"
GOTIFY_PRIORITY=6
@@ -71,14 +92,16 @@ jobs:
TARGET="staging"
IMAGE_TAG="$TAG"
ENV_FILE=".env.staging"
TRAEFIK_HOST="\`staging.${DOMAIN_BASE}\`"
TRAEFIK_HOST="\`staging.\${DOMAIN_BASE}\`"
NEXT_PUBLIC_BASE_URL="https://staging.\${DOMAIN_BASE}"
DIRECTUS_URL="https://cms.staging.\${DOMAIN_BASE}"
DIRECTUS_HOST="\`cms.staging.\${DOMAIN_BASE}\`"
PROJECT_NAME="\${PRJ_ID}-staging"
IS_PROD="false"
GOTIFY_TITLE="🧪 Staging-Deploy (Pre-Release)"
GOTIFY_PRIORITY=5
else
TARGET="skip"
GOTIFY_TITLE="❓ Unbekannter Tag"
GOTIFY_PRIORITY=3
fi
else
TARGET="skip"
@@ -88,161 +111,151 @@ jobs:
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
echo "env_file=$ENV_FILE" >> $GITHUB_OUTPUT
echo "traefik_host=$TRAEFIK_HOST" >> $GITHUB_OUTPUT
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> $GITHUB_OUTPUT
echo "directus_url=$DIRECTUS_URL" >> $GITHUB_OUTPUT
echo "directus_host=$DIRECTUS_HOST" >> $GITHUB_OUTPUT
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
echo "is_prod=$IS_PROD" >> $GITHUB_OUTPUT
echo "gotify_title=$GOTIFY_TITLE" >> $GITHUB_OUTPUT
echo "gotify_priority=$GOTIFY_PRIORITY" >> $GITHUB_OUTPUT
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "commit_msg=$COMMIT_MSG" >> $GITHUB_OUTPUT
- name: ⏭️ Skip Deployment
if: steps.determine.outputs.target == 'skip'
# ──────────────────────────────────────────────────────────────────────────────
# JOB 2: Quality Assurance (Lint & Test)
# ──────────────────────────────────────────────────────────────────────────────
qa:
name: 🧪 QA
needs: prepare
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: 🧪 Run Checks
if: github.event.inputs.skip_long_checks != 'true'
run: |
echo "Deployment übersprungen kein passender Trigger (main oder v*-Tag)"
exit 0
npm run lint
npm run typecheck
npm run test
# ──────────────────────────────────────────────────────────────────────────────
# Registry Login
# JOB 3: Build & Push
# ──────────────────────────────────────────────────────────────────────────────
build:
name: 🏗️ Build
needs: prepare
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 🔐 Registry Login
run: |
echo "🔐 Login zu registry.infra.mintel.me ..."
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
# ──────────────────────────────────────────────────────────────────────────────
# Build & Push
# ──────────────────────────────────────────────────────────────────────────────
- name: 🏗️ Docker Image bauen & pushen
- name: 🏗️ Docker Build & Push
env:
IMAGE_TAG: ${{ steps.determine.outputs.image_tag }}
NEXT_PUBLIC_BASE_URL: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_BASE_URL || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_BASE_URL || secrets.TESTING_NEXT_PUBLIC_BASE_URL || secrets.NEXT_PUBLIC_BASE_URL) }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
run: |
echo "🏗️ Building → ${{ steps.determine.outputs.target }} / $IMAGE_TAG"
docker buildx build \
--pull \
--platform linux/arm64 \
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="$NEXT_PUBLIC_UMAMI_SCRIPT_URL" \
-t registry.infra.mintel.me/${{ github.repository }}:$IMAGE_TAG \
-t registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:$IMAGE_TAG \
--push .
# ──────────────────────────────────────────────────────────────────────────────
# Deploy via SSH
# JOB 4: Deploy
# ──────────────────────────────────────────────────────────────────────────────
- name: 🚀 Deploy to ${{ steps.determine.outputs.target }}
deploy:
name: 🚀 Deploy
needs: [prepare, build, qa]
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
env:
IMAGE_TAG: ${{ steps.determine.outputs.image_tag }}
ENV_FILE: ${{ steps.determine.outputs.env_file }}
TRAEFIK_HOST: ${{ steps.determine.outputs.traefik_host }}
NEXT_PUBLIC_BASE_URL: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_BASE_URL || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_BASE_URL || secrets.TESTING_NEXT_PUBLIC_BASE_URL || secrets.NEXT_PUBLIC_BASE_URL) }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
SENTRY_DSN: ${{ steps.determine.outputs.target == 'production' && secrets.SENTRY_DSN || (steps.determine.outputs.target == 'staging' && secrets.STAGING_SENTRY_DSN || secrets.TESTING_SENTRY_DSN || secrets.SENTRY_DSN) }}
MAIL_HOST: ${{ steps.determine.outputs.target == 'production' && secrets.MAIL_HOST || (steps.determine.outputs.target == 'staging' && secrets.STAGING_MAIL_HOST || secrets.TESTING_MAIL_HOST || secrets.MAIL_HOST) }}
MAIL_PORT: ${{ steps.determine.outputs.target == 'production' && secrets.MAIL_PORT || (steps.determine.outputs.target == 'staging' && secrets.STAGING_MAIL_PORT || secrets.TESTING_MAIL_PORT || secrets.MAIL_PORT) }}
MAIL_USERNAME: ${{ steps.determine.outputs.target == 'production' && secrets.MAIL_USERNAME || (steps.determine.outputs.target == 'staging' && secrets.STAGING_MAIL_USERNAME || secrets.TESTING_MAIL_USERNAME || secrets.MAIL_USERNAME) }}
MAIL_PASSWORD: ${{ steps.determine.outputs.target == 'production' && secrets.MAIL_PASSWORD || (steps.determine.outputs.target == 'staging' && secrets.STAGING_MAIL_PASSWORD || secrets.TESTING_MAIL_PASSWORD || secrets.MAIL_PASSWORD) }}
MAIL_FROM: ${{ steps.determine.outputs.target == 'production' && secrets.MAIL_FROM || (steps.determine.outputs.target == 'staging' && secrets.STAGING_MAIL_FROM || secrets.TESTING_MAIL_FROM || secrets.MAIL_FROM) }}
MAIL_RECIPIENTS: ${{ steps.determine.outputs.target == 'production' && secrets.MAIL_RECIPIENTS || (steps.determine.outputs.target == 'staging' && secrets.STAGING_MAIL_RECIPIENTS || secrets.TESTING_MAIL_RECIPIENTS || secrets.MAIL_RECIPIENTS) }}
run: |
echo "Deploying ${{ steps.determine.outputs.target }} → $IMAGE_TAG"
TARGET: ${{ needs.prepare.outputs.target }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
# SSH vorbereiten
- name: 🚀 Deploy via SSH
env:
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
# .env-Datei erstellen
# Generate .env from secrets
cat > /tmp/app.env << EOF
# Generated by CI - ${{ steps.determine.outputs.target }} - $(date -u)
# Generated by CI - $TARGET - $(date -u)
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
SENTRY_DSN=$SENTRY_DSN
MAIL_HOST=$MAIL_HOST
MAIL_PORT=$MAIL_PORT
MAIL_USERNAME=$MAIL_USERNAME
MAIL_PASSWORD=$MAIL_PASSWORD
MAIL_FROM=$MAIL_FROM
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
IMAGE_TAG=$IMAGE_TAG
TRAEFIK_HOST=$TRAEFIK_HOST
PROJECT_NAME=$PROJECT_NAME
ENV_FILE=$ENV_FILE
# App Config
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
# Directus Config
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
DIRECTUS_HOST=${{ needs.prepare.outputs.directus_host }}
DIRECTUS_KEY=${{ secrets.DIRECTUS_KEY }}
DIRECTUS_SECRET=${{ secrets.DIRECTUS_SECRET }}
DIRECTUS_ADMIN_EMAIL=${{ secrets.DIRECTUS_ADMIN_EMAIL }}
DIRECTUS_ADMIN_PASSWORD=${{ secrets.DIRECTUS_ADMIN_PASSWORD }}
DIRECTUS_DB_PASSWORD=${{ secrets.DIRECTUS_DB_PASSWORD }}
# Gatekeeper
GATEKEEPER_PASSWORD=${{ secrets.GATEKEEPER_PASSWORD || 'mintel' }}
AUTH_MIDDLEWARE=$( [[ "$TARGET" == "production" ]] && echo "compress" || echo "$PROJECT_NAME-auth,compress" )
EOF
APP_DIR="/home/deploy/sites/${{ github.event.repository.name }}"
ssh -o StrictHostKeyChecking=accept-new root@${{ secrets.SSH_HOST }} "mkdir -p $APP_DIR"
scp -o StrictHostKeyChecking=accept-new /tmp/app.env root@${{ secrets.SSH_HOST }}:$APP_DIR/$ENV_FILE
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@${{ secrets.SSH_HOST }}:$APP_DIR/docker-compose.yml
ssh root@${{ secrets.SSH_HOST }} "mkdir -p $APP_DIR"
scp /tmp/app.env root@${{ secrets.SSH_HOST }}:$APP_DIR/$ENV_FILE
scp docker-compose.yml root@${{ secrets.SSH_HOST }}:$APP_DIR/docker-compose.yml
ssh -o StrictHostKeyChecking=accept-new root@${{ secrets.SSH_HOST }} bash << 'EOF'
ssh root@${{ secrets.SSH_HOST }} IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" PROJECT_NAME="$PROJECT_NAME" bash << 'EOF'
set -e
APP_DIR="/home/deploy/sites/${{ github.event.repository.name }}"
cd $APP_DIR
chmod 600 $ENV_FILE
chown deploy:deploy $ENV_FILE
cd "/home/deploy/sites/${{ github.event.repository.name }}"
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
echo "→ Pulling image: $IMAGE_TAG"
IMAGE_TAG=$IMAGE_TAG ENV_FILE=$ENV_FILE TRAEFIK_HOST="$TRAEFIK_HOST" docker compose --env-file $ENV_FILE pull
echo "→ Starting containers..."
IMAGE_TAG=$IMAGE_TAG ENV_FILE=$ENV_FILE TRAEFIK_HOST="$TRAEFIK_HOST" docker compose --env-file $ENV_FILE up -d
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
docker system prune -f --filter "until=168h"
echo "→ Waiting 15s for warmup..."
sleep 15
echo "→ Container status:"
docker compose --env-file $ENV_FILE ps
if ! docker compose --env-file $ENV_FILE ps | grep -q "Up"; then
echo "❌ Fehler: Container nicht Up!"
docker compose --env-file $ENV_FILE logs --tail=150
exit 1
fi
echo "✅ Deployment erfolgreich auf ${{ steps.determine.outputs.target }}!"
EOF
rm -f /tmp/app.env
# ──────────────────────────────────────────────────────────────────────────────
# Summary & Gotify
# JOB 5: Notifications
# ──────────────────────────────────────────────────────────────────────────────
- name: 📊 Deployment Summary
notifications:
name: 🔔 Notifications
needs: [prepare, deploy]
if: always()
run: |
echo "┌──────────────────────────────┐"
echo "│ Deployment Summary │"
echo "├──────────────────────────────┤"
echo "│ Status: ${{ job.status }} │"
echo "│ Umgebung: ${{ steps.determine.outputs.target || 'skipped' }} │"
echo "│ Version: ${{ steps.determine.outputs.image_tag }} │"
echo "│ Commit: ${{ steps.determine.outputs.short_sha }} │"
echo "│ Message: ${{ steps.determine.outputs.commit_msg }} │"
echo "└──────────────────────────────┘"
- name: 🔔 Gotify - Success
if: success()
runs-on: docker
steps:
- name: 🔔 Gotify
if: needs.deploy.result == 'success'
run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=${{ steps.determine.outputs.gotify_title }}" \
-F "message=Erfolgreich deployt auf **${{ steps.determine.outputs.target }}**\n\nVersion: **${{ steps.determine.outputs.image_tag }}**\nCommit: ${{ steps.determine.outputs.short_sha }} (${{ steps.determine.outputs.commit_msg }})\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}" \
-F "priority=${{ steps.determine.outputs.gotify_priority }}" || true
- name: 🔔 Gotify - Failure
if: failure()
run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=❌ Deployment FEHLGESCHLAGEN ${{ steps.determine.outputs.target || 'unknown' }}" \
-F "message=**Fehler beim Deploy auf ${{ steps.determine.outputs.target }}**\n\nVersion: ${{ steps.determine.outputs.image_tag || '?' }}\nCommit: ${{ steps.determine.outputs.short_sha || '?' }}\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}\n\nBitte Logs prüfen!" \
-F "priority=8" || true
-F "title=${{ needs.prepare.outputs.gotify_title }}" \
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**" \
-F "priority=4" || true

View File

@@ -7,6 +7,13 @@
},
"files": [
"docker",
"gitea"
]
"gitea",
"templates"
],
"devDependencies": {
"@directus/sdk": "^21.0.0",
"@mintel/next-utils": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,29 @@
# dependencies
/node_modules
/.pnpm-debug.log*
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,25 @@
import client, { ensureAuthenticated } from "../src/lib/directus";
import { updateSettings } from "@directus/sdk";
async function setupBranding() {
console.log("🎨 Setup Directus Branding...");
await ensureAuthenticated();
try {
await client.request(
updateSettings({
project_name: process.env.PROJECT_NAME || "Mintel Project",
project_color: process.env.PROJECT_COLOR || "#82ed20",
theme_light_overrides: {
primary: process.env.PROJECT_COLOR || "#82ed20",
borderRadius: "16px",
},
} as any),
);
console.log("✨ Branding applied!");
} catch (error) {
console.error("❌ Error setting up branding:", error);
}
}
setupBranding();

View File

@@ -0,0 +1,45 @@
@import "tailwindcss";
@theme {
--font-sans:
"Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--font-heading: "Inter", system-ui, sans-serif;
--font-body: "Inter", system-ui, sans-serif;
--color-primary: #001a4d;
--color-primary-dark: #000d26;
--color-primary-light: #e6ebf5;
--color-accent: #82ed20;
--color-accent-dark: #6bc41a;
--color-accent-light: #f0f9e6;
--color-text-primary: #111827;
--color-text-secondary: #4b5563;
--color-text-light: #9ca3af;
}
@layer base {
body {
@apply antialiased bg-white text-text-primary;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-heading font-bold tracking-tight text-primary;
}
}
@utility touch-target {
min-height: 48px;
min-width: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,12 @@
import {
createMintelDirectusClient,
ensureDirectusAuthenticated,
} from "@mintel/next-utils";
const client = createMintelDirectusClient(process.env.DIRECTUS_URL);
export async function ensureAuthenticated() {
await ensureDirectusAuthenticated(client);
}
export default client;

View File

@@ -0,0 +1,12 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@mintel/next-utils": ["../next-utils/src/index.ts"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

5
packages/next-config/index.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
import { NextConfig } from "next";
declare function mintelNextConfig(config: NextConfig): NextConfig;
export default mintelNextConfig;
export const baseNextConfig: NextConfig;

View File

@@ -7,6 +7,13 @@
},
"type": "module",
"main": "index.js",
"types": "index.d.ts",
"exports": {
".": {
"types": "./index.d.ts",
"default": "./index.js"
}
},
"dependencies": {
"next-intl": "^3.0.0",
"@sentry/nextjs": "^8.0.0"

View File

@@ -0,0 +1,3 @@
import baseConfig from "@mintel/eslint-config";
export default baseConfig;

View File

@@ -11,17 +11,20 @@
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"dev": "tsup src/index.ts --format cjs,esm --watch --dts",
"lint": "eslint src/"
"lint": "eslint src/",
"test": "vitest run"
},
"dependencies": {
"@directus/sdk": "^21.0.0",
"next": "15.1.6",
"next-intl": "^3.0.0",
"zod": "^3.0.0"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"@mintel/eslint-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@mintel/eslint-config": "workspace:*"
"eslint": "^9.39.2",
"tsup": "^8.0.0",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,47 @@
import {
createDirectus,
rest,
authentication,
DirectusClient,
RestClient,
AuthenticationClient,
} from "@directus/sdk";
export type MintelDirectusClient = DirectusClient<any> &
RestClient<any> &
AuthenticationClient<any>;
/**
* Creates a Directus client configured with Mintel standards
*/
export function createMintelDirectusClient(url?: string): MintelDirectusClient {
const directusUrl =
url || process.env.DIRECTUS_URL || "http://localhost:8055";
return createDirectus(directusUrl).with(rest()).with(authentication());
}
/**
* Ensures the client is authenticated using either a static token or admin credentials
*/
export async function ensureDirectusAuthenticated(
client: MintelDirectusClient,
) {
const token = process.env.DIRECTUS_API_TOKEN || process.env.DIRECTUS_TOKEN;
const email = process.env.DIRECTUS_ADMIN_EMAIL;
const password = process.env.DIRECTUS_ADMIN_PASSWORD;
if (token) {
client.setToken(token);
return;
}
if (email && password) {
try {
await client.login({ email, password });
} catch (e) {
console.error("Failed to authenticate with Directus:", e);
throw e;
}
}
}

View File

@@ -1,7 +1,6 @@
import createMiddleware from 'next-intl/middleware';
import { getRequestConfig } from 'next-intl/server';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import createMiddleware from "next-intl/middleware";
import { getRequestConfig } from "next-intl/server";
import type { NextRequest } from "next/server";
export interface MintelI18nConfig {
locales: string[];
@@ -25,7 +24,10 @@ export function createMintelMiddleware(config: MintelI18nConfig) {
return intlMiddleware(request);
} catch (error) {
if (config.logRequests) {
console.error(`Request failed: ${request.method} ${request.url}`, error);
console.error(
`Request failed: ${request.method} ${request.url}`,
error,
);
}
throw error;
}
@@ -35,7 +37,7 @@ export function createMintelMiddleware(config: MintelI18nConfig) {
export function createMintelI18nRequestConfig(
locales: string[],
defaultLocale: string,
importMessages: (locale: string) => Promise<any>
importMessages: (locale: string) => Promise<any>,
) {
return getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
@@ -48,19 +50,19 @@ export function createMintelI18nRequestConfig(
locale,
messages: (await importMessages(locale)).default,
onError(error: any) {
if (error.code === 'MISSING_MESSAGE') {
if (error.code === "MISSING_MESSAGE") {
console.error(error.message);
} else {
console.error(error);
}
},
getMessageFallback({ namespace, key, error }: any) {
const path = [namespace, key].filter((part) => part != null).join('.');
if (error.code === 'MISSING_MESSAGE') {
const path = [namespace, key].filter((part) => part != null).join(".");
if (error.code === "MISSING_MESSAGE") {
return path;
}
return 'fallback';
}
return "fallback";
},
};
});
}

View File

@@ -0,0 +1,10 @@
import { describe, it, expect } from "vitest";
import { isValidLang } from "../src/index";
describe("next-utils", () => {
it("should validate languages correctly", () => {
expect(isValidLang("en")).toBe(true);
expect(isValidLang("de")).toBe(true);
expect(isValidLang("fr")).toBe(false);
});
});

View File

@@ -3,7 +3,11 @@ const submissions: Record<string, number> = {};
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
const MAX_SUBMISSIONS_PER_WINDOW = 3;
export async function rateLimit(identifier: string, windowMs = RATE_LIMIT_WINDOW, maxSubmissions = MAX_SUBMISSIONS_PER_WINDOW) {
export async function rateLimit(
identifier: string,
windowMs = RATE_LIMIT_WINDOW,
maxSubmissions = MAX_SUBMISSIONS_PER_WINDOW,
) {
const now = Date.now();
// Clean up old submissions
@@ -15,7 +19,7 @@ export async function rateLimit(identifier: string, windowMs = RATE_LIMIT_WINDOW
// Check if identifier has exceeded submission limit
const currentSubmissions = Object.values(submissions).filter(
(timestamp) => now - timestamp <= windowMs
(timestamp) => now - timestamp <= windowMs,
);
if (currentSubmissions.length >= maxSubmissions) {
@@ -35,3 +39,4 @@ export function isValidLang(lang: string): lang is Lang {
export * from "./i18n";
export * from "./env";
export * from "./directus";

2118
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

10
tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@mintel/*": ["packages/*/index.js"],
"@/*": ["src/*"]
}
},
"exclude": ["node_modules", "dist"]
}