init
Some checks failed
Code Quality / lint-and-build (push) Failing after 29s
Release Packages / release (push) Failing after 41s

This commit is contained in:
2026-01-31 19:26:46 +01:00
commit 9a0900e3ff
42 changed files with 8346 additions and 0 deletions

8
.changeset/README.md Normal file
View File

@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

11
.changeset/config.json Normal file
View File

@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

36
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,36 @@
name: Code Quality
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-build:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Lint
run: pnpm lint
- name: Build
run: pnpm build

View File

@@ -0,0 +1,38 @@
name: Release Packages
on:
push:
branches:
- main
jobs:
release:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create Release Pull Request or Publish
id: changesets
run: pnpm release
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# dependencies
node_modules
.pnpm-debug.log*
# next.js
.next/
out/
# build outputs
dist/
build/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# OS files
.DS_Store
Thumbs.db
# IDE files
.vscode/
.idea/
# Changesets
.changeset/*.lock

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
@mintel:registry=https://npm.infra.mintel.me
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
always-auth=true

77
README.md Normal file
View File

@@ -0,0 +1,77 @@
# Mintel Monorepo
This monorepo manages multiple client websites using a shared technology stack: Next.js, TypeScript, and React.
## Project Structure
- `apps/`: Client websites (e.g., `sample-website`).
- `packages/`: Shared packages under the `@mintel` namespace.
- `@mintel/tsconfig`: Shared TypeScript configurations.
- `@mintel/eslint-config`: Shared ESLint configurations.
- `@mintel/next-config`: Shared Next.js configuration wrapper.
- `@mintel/next-utils`: Reusable logic (i18n, rate limiting, etc.).
- `@mintel/infra`: Infrastructure templates (Docker, Gitea Actions).
- `@mintel/cli`: CLI tool for project setup and migration.
## Getting Started
### Prerequisites
- [pnpm](https://pnpm.io/) (v10+)
- Node.js (v20+)
### Installation
```bash
pnpm install
```
### Development
```bash
pnpm dev
```
### Building
```bash
pnpm build
```
## Creating a New Project
Use the Mintel CLI to set up a new project or migrate an existing one:
```bash
pnpm --filter @mintel/cli start init apps/my-new-website
```
## Versioning and Releasing
We use [Changesets](https://github.com/changesets/changesets) for version management.
### 1. Add a changeset
When you make a change that requires a version bump, run:
```bash
pnpm changeset
```
### 2. Version packages
To bump versions based on accumulated changesets:
```bash
pnpm version-packages
```
### 3. Publish to registry
To build and publish all changed packages to the private registry:
```bash
pnpm release
```
## Deployment
Projects are hosted on Hetzner with Docker and Traefik, deployed via Gitea Actions. See `@mintel/infra` for templates.
## Registry
Private npm registry: [https://npm.infra.mintel.me](https://npm.infra.mintel.me)

View File

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

View File

@@ -0,0 +1,6 @@
import mintelNextConfig from "@mintel/next-config";
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default mintelNextConfig(nextConfig);

View File

@@ -0,0 +1,27 @@
{
"name": "sample-website",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "15.1.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@mintel/next-utils": "workspace:*"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.0.0",
"@mintel/tsconfig": "workspace:*",
"@mintel/eslint-config": "workspace:*",
"@mintel/next-config": "workspace:*"
}
}

View File

@@ -0,0 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Sample Website",
description: "A sample website using @mintel shared packages",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { isValidLang, rateLimit } from "@mintel/next-utils";
export default function Home() {
const testLang = "en";
const isLangValid = isValidLang(testLang);
const handleTestRateLimit = async () => {
try {
await rateLimit("test-user");
console.log("Rate limit check passed");
} catch (e) {
console.error(e);
}
};
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
<h1 className="text-4xl font-bold">Sample Website</h1>
<p className="mt-4">
Testing @mintel/next-utils:
<br />
Is {'"'}en{'"'} valid? {isLangValid ? "Yes" : "No"}
</p>
<button
onClick={handleTestRateLimit}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
>
Test Rate Limit
</button>
</div>
</main>
);
}

View File

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

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "@mintel/monorepo",
"private": true,
"scripts": {
"build": "pnpm --filter \"./packages/*\" build",
"dev": "pnpm --filter \"./packages/*\" dev",
"lint": "pnpm --filter \"./packages/*\" lint",
"test": "pnpm --filter \"./packages/*\" test",
"changeset": "changeset",
"version-packages": "changeset version",
"release": "pnpm build && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.29.8",
"prettier": "^3.0.0",
"typescript": "^5.0.0"
}
}

26
packages/cli/README.md Normal file
View File

@@ -0,0 +1,26 @@
# @mintel/cli
CLI tool for managing the Mintel monorepo and scaffolding new client websites.
## Installation
```bash
pnpm install
```
## Commands
### `init <path>`
Initializes a new website project in the specified path (relative to the monorepo root).
```bash
pnpm --filter @mintel/cli start init apps/my-new-website
```
This command will:
1. Create the project directory.
2. Generate `package.json`, `tsconfig.json`, and `eslint.config.mjs` extending `@mintel` defaults.
3. Set up a localized Next.js structure (`src/app/[locale]`).
4. Configure `next-intl` middleware and request config.
5. Inject production-ready `Dockerfile`, `docker-compose.yml`, and Gitea Actions deployment workflows.

30
packages/cli/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "@mintel/cli",
"version": "1.0.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
},
"type": "module",
"bin": {
"mintel": "./dist/index.js"
},
"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"
},
"dependencies": {
"commander": "^11.0.0",
"fs-extra": "^11.1.0",
"chalk": "^5.3.0",
"prompts": "^2.4.2"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"@types/fs-extra": "^11.0.0",
"@types/prompts": "^2.4.4",
"@mintel/tsconfig": "workspace:*"
}
}

228
packages/cli/src/index.ts Normal file
View File

@@ -0,0 +1,228 @@
import { Command } from "commander";
import fs from "fs-extra";
import path from "path";
import chalk from "chalk";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const program = new Command();
program
.name("mintel")
.description("CLI for Mintel monorepo management")
.version("1.0.0");
program
.command("init <path>")
.description("Initialize a new website project")
.action(async (projectPath) => {
const fullPath = path.isAbsolute(projectPath)
? projectPath
: path.resolve(process.cwd(), "../../", projectPath);
const projectName = path.basename(fullPath);
console.log(chalk.blue(`Initializing new project: ${projectName}...`));
try {
// Create directory
await fs.ensureDir(fullPath);
// Create package.json
const pkgJson = {
name: projectName,
version: "0.1.0",
private: true,
type: "module",
scripts: {
dev: "next dev",
build: "next build",
start: "next start",
lint: "next lint",
},
dependencies: {
next: "15.1.6",
react: "^19.0.0",
"react-dom": "^19.0.0",
"@mintel/next-utils": "workspace:*",
},
devDependencies: {
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
typescript: "^5.0.0",
"@mintel/tsconfig": "workspace:*",
"@mintel/eslint-config": "workspace:*",
"@mintel/next-config": "workspace:*",
},
};
await fs.writeJson(path.join(fullPath, "package.json"), pkgJson, {
spaces: 2,
});
// Create next.config.ts
const nextConfig = `import mintelNextConfig from "@mintel/next-config";
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default mintelNextConfig(nextConfig);
`;
await fs.writeFile(path.join(fullPath, "next.config.ts"), nextConfig);
// Create tsconfig.json
const tsConfig = {
extends: "@mintel/tsconfig/nextjs.json",
compilerOptions: {
paths: {
"@/*": ["./src/*"],
},
},
include: [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
],
exclude: ["node_modules"],
};
await fs.writeJson(path.join(fullPath, "tsconfig.json"), tsConfig, {
spaces: 2,
});
// Create eslint.config.mjs
const eslintConfig = `import { nextConfig } from "@mintel/eslint-config/next";
export default nextConfig;
`;
await fs.writeFile(
path.join(fullPath, "eslint.config.mjs"),
eslintConfig
);
// Create basic src structure
await fs.ensureDir(path.join(fullPath, "src/app/[locale]"));
await fs.writeFile(
path.join(fullPath, "src/middleware.ts"),
`import { createMintelMiddleware } from "@mintel/next-utils";
export default createMintelMiddleware({
locales: ["en", "de"],
defaultLocale: "en",
logRequests: true,
});
export const config = {
matcher: ["/((?!api|_next|_vercel|health|.*\\\\..*).*)", "/", "/(de|en)/:path*"]
};
`
);
// Create i18n/request.ts
await fs.ensureDir(path.join(fullPath, "src/i18n"));
await fs.writeFile(
path.join(fullPath, "src/i18n/request.ts"),
`import { createMintelI18nRequestConfig } from "@mintel/next-utils";
export default createMintelI18nRequestConfig(
["en", "de"],
"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"), {
Index: {
title: "Welcome"
}
}, { spaces: 2 });
await fs.writeJson(path.join(fullPath, "messages/de.json"), {
Index: {
title: "Willkommen"
}
}, { spaces: 2 });
// Create instrumentation.ts
await fs.writeFile(
path.join(fullPath, "src/instrumentation.ts"),
`import * as Sentry from '@sentry/nextjs';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Server-side initialization
}
}
export const onRequestError = Sentry.captureRequestError;
`
);
await fs.writeFile(
path.join(fullPath, "src/app/[locale]/layout.tsx"),
`import type { Metadata } from "next";
export const metadata: Metadata = {
title: "${projectName}",
description: "Created with Mintel CLI",
};
export default function RootLayout({
children,
params: { locale }
}: {
children: React.ReactNode;
params: { locale: string };
}) {
return (
<html lang={locale}>
<body>{children}</body>
</html>
);
}
`
);
await fs.writeFile(
path.join(fullPath, "src/app/[locale]/page.tsx"),
`export default function Home() {
return (
<main>
<h1>Welcome to ${projectName}</h1>
</main>
);
}
`
);
// Copy infra templates
const infraPath = path.resolve(__dirname, "../../infra");
if (await fs.pathExists(infraPath)) {
await fs.copy(
path.join(infraPath, "docker/Dockerfile.nextjs"),
path.join(fullPath, "Dockerfile")
);
await fs.copy(
path.join(infraPath, "docker/docker-compose.template.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")
);
}
console.log(
chalk.green(`Successfully initialized ${projectName} at ${fullPath}`)
);
console.log(chalk.yellow("\nNext steps:"));
console.log(chalk.cyan("1. pnpm install"));
console.log(chalk.cyan(`2. cd ${projectPath} && pnpm dev`));
} catch (error) {
console.error(chalk.red("Error initializing project:"), error);
}
});
program.parse();

View File

@@ -0,0 +1,11 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["esnext"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,13 @@
# @mintel/eslint-config
Shared ESLint configurations for Mintel projects.
## Usage
### Next.js
In your `eslint.config.mjs`:
```javascript
import { nextConfig } from "@mintel/eslint-config/next";
export default nextConfig;
```

View File

@@ -0,0 +1,24 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
export const nextConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-require-imports": "off",
"prefer-const": "warn",
"react/no-unescaped-entities": "off",
"@next/next/no-img-element": "warn"
}
}
];

View File

@@ -0,0 +1,18 @@
{
"name": "@mintel/eslint-config",
"version": "1.0.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
},
"type": "module",
"main": "index.js",
"exports": {
".": "./index.js",
"./next": "./next.js"
},
"dependencies": {
"@eslint/eslintrc": "^3.0.0",
"eslint-config-next": "15.1.6"
}
}

12
packages/infra/README.md Normal file
View File

@@ -0,0 +1,12 @@
# @mintel/infra
Infrastructure templates for Mintel projects.
## Contents
- `docker/`: Universal `Dockerfile.nextjs` and `docker-compose.template.yml`.
- `gitea/`: Production-ready `deploy-action.yml` for Gitea Actions.
## Usage
These files are automatically injected into new projects by the `@mintel/cli`.

View File

@@ -0,0 +1,66 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat curl
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* pnpm-lock.yaml* ./
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
else npm i; fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
ENV NEXT_TELEMETRY_DISABLED=1
# Build-time environment variables for Next.js
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
# Build the application
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else npm run build; fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
# Install curl for health checks
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
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
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

@@ -0,0 +1,33 @@
services:
app:
image: registry.infra.mintel.me/${PROJECT_NAME:-mintel/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"
# 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"
# 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"
networks:
infra:
external: true

View File

@@ -0,0 +1,197 @@
name: Build & Deploy
on:
push:
branches: [main, staging]
jobs:
build-and-deploy:
runs-on: docker
steps:
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Workflow Start - Full Transparency
# ═══════════════════════════════════════════════════════════════════════════════
- name: 📋 Log Workflow Start
run: |
echo "🚀 Starting deployment for ${{ github.repository }} (${{ github.ref }})"
echo " • Branch: ${{ github.ref_name }}"
echo " • Commit: ${{ github.sha }}"
echo " • Timestamp: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
- name: Checkout repository
uses: actions/checkout@v4
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Registry Login Phase
# ═══════════════════════════════════════════════════════════════════════════════
- name: 🔐 Login to private registry
run: |
echo "🔐 Authenticating with registry.infra.mintel.me..."
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Build Phase
# ═══════════════════════════════════════════════════════════════════════════════
- name: 🏗️ Build Docker image
env:
NEXT_PUBLIC_BASE_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_BASE_URL || (secrets.STAGING_NEXT_PUBLIC_BASE_URL || secrets.NEXT_PUBLIC_BASE_URL) }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
run: |
echo "🏗️ Building Docker image (linux/arm64) for branch ${{ github.ref_name }}..."
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 }}:${{ github.sha }} \
-t registry.infra.mintel.me/${{ github.repository }}:latest \
--push .
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Deployment Phase
# ═══════════════════════════════════════════════════════════════════════════════
- name: 🚀 Deploy to server
env:
NEXT_PUBLIC_BASE_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_BASE_URL || (secrets.STAGING_NEXT_PUBLIC_BASE_URL || secrets.NEXT_PUBLIC_BASE_URL) }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
SENTRY_DSN: ${{ github.ref_name == 'main' && secrets.SENTRY_DSN || (secrets.STAGING_SENTRY_DSN || secrets.SENTRY_DSN) }}
MAIL_HOST: ${{ github.ref_name == 'main' && secrets.MAIL_HOST || (secrets.STAGING_MAIL_HOST || secrets.MAIL_HOST) }}
MAIL_PORT: ${{ github.ref_name == 'main' && secrets.MAIL_PORT || (secrets.STAGING_MAIL_PORT || secrets.MAIL_PORT) }}
MAIL_USERNAME: ${{ github.ref_name == 'main' && secrets.MAIL_USERNAME || (secrets.STAGING_MAIL_USERNAME || secrets.MAIL_USERNAME) }}
MAIL_PASSWORD: ${{ github.ref_name == 'main' && secrets.MAIL_PASSWORD || (secrets.STAGING_MAIL_PASSWORD || secrets.MAIL_PASSWORD) }}
MAIL_FROM: ${{ github.ref_name == 'main' && secrets.MAIL_FROM || (secrets.STAGING_MAIL_FROM || secrets.MAIL_FROM) }}
MAIL_RECIPIENTS: ${{ github.ref_name == 'main' && secrets.MAIL_RECIPIENTS || (secrets.STAGING_MAIL_RECIPIENTS || secrets.MAIL_RECIPIENTS) }}
run: |
BRANCH=${{ github.ref_name }}
# Derive domain from NEXT_PUBLIC_BASE_URL (strip https:// and trailing slash)
DOMAIN=$(echo "$NEXT_PUBLIC_BASE_URL" | sed -E 's|https?://||' | sed -E 's|/.*||')
if [ "$BRANCH" = "main" ]; then
ENV_FILE=.env.prod
# For production, we want both root and www
TRAEFIK_HOST="\`$DOMAIN\`, \`www.$DOMAIN\`"
else
ENV_FILE=.env.staging
TRAEFIK_HOST="\`$DOMAIN\`"
fi
echo "🚀 Deploying branch $BRANCH to $ENV_FILE..."
echo "🌐 Domain: $DOMAIN"
# Setup SSH
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
# Create .env file content
cat > /tmp/app.env << EOF
# ============================================================================
# Environment Configuration ($BRANCH)
# ============================================================================
# Auto-generated by CI/CD workflow
# ============================================================================
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
# Deployment variables for docker-compose
IMAGE_TAG=${{ github.sha }}
TRAEFIK_HOST=$TRAEFIK_HOST
ENV_FILE=$ENV_FILE
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 -o StrictHostKeyChecking=accept-new root@${{ secrets.SSH_HOST }} bash << EOF
set -e
cd $APP_DIR
chmod 600 $ENV_FILE
chown deploy:deploy $ENV_FILE
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
echo "📥 Pulling images..."
IMAGE_TAG=${{ github.sha }} ENV_FILE=$ENV_FILE TRAEFIK_HOST="$TRAEFIK_HOST" docker compose --env-file $ENV_FILE pull
echo "🚀 Starting containers..."
IMAGE_TAG=${{ github.sha }} ENV_FILE=$ENV_FILE TRAEFIK_HOST="$TRAEFIK_HOST" docker compose --env-file $ENV_FILE up -d
echo "🧹 Cleaning up old images..."
docker system prune -f
echo "⏳ Giving the app a few seconds to warm up..."
sleep 10
echo "🔍 Checking container status..."
docker compose --env-file $ENV_FILE ps
if ! docker compose --env-file $ENV_FILE ps | grep -q "Up"; then
echo "❌ Container failed to start"
docker compose --env-file $ENV_FILE logs --tail=100
exit 1
fi
echo "✅ Deployment complete!"
EOF
rm -f /tmp/app.env
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Workflow Summary
# ═══════════════════════════════════════════════════════════════════════════════
- name: 📊 Workflow Summary
if: always()
run: |
echo "📊 Status: ${{ job.status }}"
echo "🎯 Target: ${{ secrets.SSH_HOST }}"
echo "🌿 Branch: ${{ github.ref_name }}"
# ═══════════════════════════════════════════════════════════════════════════════
# NOTIFICATION: Gotify
# ═══════════════════════════════════════════════════════════════════════════════
- name: 🔔 Gotify Notification (Success)
if: success()
run: |
echo "Sending success notification to Gotify..."
curl -k -s -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=✅ Deployment Success: ${{ github.repository }}" \
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref_name }}) was successful.
Commit: ${{ github.sha }}
Actor: ${{ github.actor }}
Run ID: ${{ github.run_id }}" \
-F "priority=5"
- name: 🔔 Gotify Notification (Failure)
if: failure()
run: |
echo "Sending failure notification to Gotify..."
curl -k -s -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=❌ Deployment Failed: ${{ github.repository }}" \
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref_name }}) failed!
Commit: ${{ github.sha }}
Actor: ${{ github.actor }}
Run ID: ${{ github.run_id }}
Please check the logs for details." \
-F "priority=8"

View File

@@ -0,0 +1,12 @@
{
"name": "@mintel/infra",
"version": "1.0.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
},
"files": [
"docker",
"gitea"
]
}

View File

@@ -0,0 +1,16 @@
# @mintel/next-config
Shared Next.js configuration wrapper for Mintel projects. Integrates `next-intl` and Sentry by default.
## Usage
In your `next.config.ts`:
```typescript
import mintelNextConfig from "@mintel/next-config";
const nextConfig = {
// Your project specific config
};
export default mintelNextConfig(nextConfig);
```

View File

@@ -0,0 +1,46 @@
import createNextIntlPlugin from 'next-intl/plugin';
import { withSentryConfig } from '@sentry/nextjs';
const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
export const baseNextConfig = {
output: 'standalone',
images: {
dangerouslyAllowSVG: true,
contentDispositionType: 'attachment',
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
},
async rewrites() {
const umamiUrl = (process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL || 'https://analytics.infra.mintel.me').replace('/script.js', '');
const glitchtipUrl = process.env.SENTRY_DSN
? new URL(process.env.SENTRY_DSN).origin
: 'https://errors.infra.mintel.me';
return [
{
source: '/stats/:path*',
destination: `${umamiUrl}/:path*`,
},
{
source: '/errors/:path*',
destination: `${glitchtipUrl}/:path*`,
},
];
},
};
export default (config) => {
const nextIntlConfig = withNextIntl({ ...baseNextConfig, ...config });
return withSentryConfig(
nextIntlConfig,
{
silent: !process.env.CI,
treeshake: { removeDebugLogging: true },
},
{
authToken: undefined,
}
);
};

View File

@@ -0,0 +1,14 @@
{
"name": "@mintel/next-config",
"version": "1.0.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
},
"type": "module",
"main": "index.js",
"dependencies": {
"next-intl": "^3.0.0",
"@sentry/nextjs": "^8.0.0"
}
}

View File

@@ -0,0 +1,28 @@
# @mintel/next-utils
Reusable utilities for Mintel Next.js projects.
## Features
- **i18n**: Standardized middleware and request configuration for `next-intl`.
- **Env Validation**: Zod-based environment variable validation.
- **Rate Limiting**: Simple in-memory rate limiting for server actions.
## Usage
### i18n Middleware
```typescript
import { createMintelMiddleware } from "@mintel/next-utils";
export default createMintelMiddleware({
locales: ["en", "de"],
defaultLocale: "en",
});
```
### Env Validation
```typescript
import { validateMintelEnv } from "@mintel/next-utils";
const env = validateMintelEnv();
```

View File

@@ -0,0 +1,27 @@
{
"name": "@mintel/next-utils",
"version": "1.0.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"dev": "tsup src/index.ts --format cjs,esm --watch --dts",
"lint": "eslint src/"
},
"dependencies": {
"next": "15.1.6",
"next-intl": "^3.0.0",
"zod": "^3.0.0"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"@mintel/tsconfig": "workspace:*",
"@mintel/eslint-config": "workspace:*"
}
}

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
export const mintelEnvSchema = {
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
NEXT_PUBLIC_BASE_URL: z.string().url(),
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().default('https://analytics.infra.mintel.me/script.js'),
SENTRY_DSN: z.string().optional(),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
MAIL_HOST: z.string().optional(),
MAIL_PORT: z.coerce.number().default(587),
MAIL_USERNAME: z.string().optional(),
MAIL_PASSWORD: z.string().optional(),
MAIL_FROM: z.string().optional(),
MAIL_RECIPIENTS: z.preprocess(
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
z.array(z.string()).default([])
),
};
export function validateMintelEnv(schemaExtension = {}) {
const fullSchema = z.object({
...mintelEnvSchema,
...schemaExtension,
});
const result = fullSchema.safeParse(process.env);
if (!result.success) {
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
throw new Error('Invalid environment variables');
}
return result.data;
}

View File

@@ -0,0 +1,66 @@
import createMiddleware from 'next-intl/middleware';
import { getRequestConfig } from 'next-intl/server';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export interface MintelI18nConfig {
locales: string[];
defaultLocale: string;
logRequests?: boolean;
}
export function createMintelMiddleware(config: MintelI18nConfig) {
const intlMiddleware = createMiddleware({
locales: config.locales,
defaultLocale: config.defaultLocale,
});
return function middleware(request: NextRequest) {
if (config.logRequests) {
const { method, url } = request;
console.log(`Incoming request: method=${method} url=${url}`);
}
try {
return intlMiddleware(request);
} catch (error) {
if (config.logRequests) {
console.error(`Request failed: ${request.method} ${request.url}`, error);
}
throw error;
}
};
}
export function createMintelI18nRequestConfig(
locales: string[],
defaultLocale: string,
importMessages: (locale: string) => Promise<any>
) {
return getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !locales.includes(locale)) {
locale = defaultLocale;
}
return {
locale,
messages: (await importMessages(locale)).default,
onError(error: any) {
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') {
return path;
}
return 'fallback';
}
};
});
}

View File

@@ -0,0 +1,37 @@
// Simple in-memory rate limiting
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) {
const now = Date.now();
// Clean up old submissions
Object.keys(submissions).forEach((key) => {
if (now - submissions[key] > windowMs) {
delete submissions[key];
}
});
// Check if identifier has exceeded submission limit
const currentSubmissions = Object.values(submissions).filter(
(timestamp) => now - timestamp <= windowMs
);
if (currentSubmissions.length >= maxSubmissions) {
throw new Error("Too many submissions. Please try again later.");
}
// Record this submission
submissions[identifier] = now;
}
export const languages = ["en", "de"] as const;
export type Lang = (typeof languages)[number];
export function isValidLang(lang: string): lang is Lang {
return (languages as readonly string[]).includes(lang);
}
export * from "./i18n";
export * from "./env";

View File

@@ -0,0 +1,9 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"lib": ["es2017", "dom"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,21 @@
# @mintel/tsconfig
Shared TypeScript configurations for Mintel projects.
## Usage
### Base Configuration
In your `tsconfig.json`:
```json
{
"extends": "@mintel/tsconfig/base.json"
}
```
### Next.js Configuration
In your `tsconfig.json`:
```json
{
"extends": "@mintel/tsconfig/nextjs.json"
}
```

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Base",
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": false
}
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Next.js",
"extends": "./base.json",
"compilerOptions": {
"plugins": [
{
"name": "next"
}
]
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "@mintel/tsconfig",
"version": "1.0.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
},
"files": [
"base.json",
"nextjs.json"
]
}

6987
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
packages:
- 'packages/*'
- 'apps/*'