Skip to content

Commit f04b56f

Browse files
committed
feat: add Makefile, multi-platform Docker setup, CI workflow, and tests
1 parent 5ffea08 commit f04b56f

8 files changed

Lines changed: 385 additions & 1 deletion

File tree

‎.dockerignore‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
node_modules
2+
build
3+
.docusaurus
4+
.cache-loader
5+
.git
6+
.github
7+
*.log
8+
.DS_Store
9+
.env*
10+
README.md
11+
LICENSE
12+
Makefile

‎.github/workflows/ci.yml‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: CI & Multi-Platform Docker Build
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
tags:
8+
- 'v*.*.*'
9+
pull_request:
10+
branches:
11+
- main
12+
workflow_dispatch:
13+
14+
permissions:
15+
contents: read
16+
packages: write
17+
18+
jobs:
19+
test-and-build:
20+
name: Test & Build Documentation
21+
runs-on: ubuntu-latest
22+
steps:
23+
- name: Checkout repository
24+
uses: actions/checkout@v4
25+
26+
- name: Setup Node.js 22
27+
uses: actions/setup-node@v4
28+
with:
29+
node-version: 22
30+
cache: 'npm'
31+
32+
- name: Install dependencies
33+
run: make install
34+
35+
- name: Run tests
36+
run: make test
37+
38+
- name: Build static site
39+
run: make build
40+
41+
docker:
42+
name: Build Multi-Platform Docker Image
43+
needs: test-and-build
44+
runs-on: ubuntu-latest
45+
steps:
46+
- name: Checkout repository
47+
uses: actions/checkout@v4
48+
49+
- name: Set up QEMU
50+
uses: docker/setup-qemu-action@v3
51+
52+
- name: Set up Docker Buildx
53+
uses: docker/setup-buildx-action@v3
54+
55+
- name: Log in to GitHub Container Registry
56+
if: github.event_name != 'pull_request'
57+
uses: docker/login-action@v3
58+
with:
59+
registry: ghcr.io
60+
username: ${{ github.actor }}
61+
password: ${{ secrets.GITHUB_TOKEN }}
62+
63+
- name: Extract Docker metadata
64+
id: meta
65+
uses: docker/metadata-action@v5
66+
with:
67+
images: ghcr.io/${{ github.repository }}
68+
tags: |
69+
type=ref,event=branch
70+
type=ref,event=pr
71+
type=semver,pattern={{version}}
72+
type=semver,pattern={{major}}.{{minor}}
73+
type=sha,prefix=sha-,format=short
74+
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
75+
76+
- name: Build and push multi-platform image
77+
uses: docker/build-push-action@v6
78+
with:
79+
context: .
80+
file: ./docker/Dockerfile
81+
platforms: linux/amd64,linux/arm64
82+
push: ${{ github.event_name != 'pull_request' }}
83+
tags: ${{ steps.meta.outputs.tags }}
84+
labels: ${{ steps.meta.outputs.labels }}
85+
cache-from: type=gha
86+
cache-to: type=gha,mode=max

‎Makefile‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Variables
2+
IMAGE_NAME ?= ghcr.io/mylists/docs
3+
TAG ?= latest
4+
PLATFORMS ?= linux/amd64,linux/arm64
5+
BUILDER_NAME ?= docs-multiarch-builder
6+
PORT ?= 8080
7+
DOCKERFILE ?= docker/Dockerfile
8+
9+
.PHONY: help all install start build serve test clean docker-build docker-buildx-builder docker-buildx docker-buildx-push docker-run
10+
11+
# Default target
12+
all: install test build
13+
14+
help: ## Show this help message
15+
@echo "Available commands:"
16+
@awk 'BEGIN {FS = ":.*?## "}; /^[a-zA-Z_-]+:.*?## .*$$/ {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
17+
18+
install: ## Install npm dependencies cleanly
19+
npm ci
20+
21+
start: ## Start local Docusaurus development server
22+
npm run start
23+
24+
build: ## Build static production site
25+
npm run build
26+
27+
serve: ## Preview built production site locally
28+
npm run serve
29+
30+
test: ## Run test suite
31+
npm test
32+
33+
clean: ## Remove build outputs and docusaurus cache
34+
rm -rf build .docusaurus
35+
36+
docker-build: ## Build single-platform Docker image locally
37+
docker build -f $(DOCKERFILE) -t $(IMAGE_NAME):$(TAG) .
38+
39+
docker-buildx-builder: ## Setup Docker Buildx builder instance for multi-platform builds
40+
@docker buildx inspect $(BUILDER_NAME) >/dev/null 2>&1 || docker buildx create --name $(BUILDER_NAME) --use
41+
@docker buildx use $(BUILDER_NAME)
42+
43+
docker-buildx: docker-buildx-builder ## Build multi-platform Docker image without pushing (linux/amd64, linux/arm64)
44+
docker buildx build --platform $(PLATFORMS) -f $(DOCKERFILE) -t $(IMAGE_NAME):$(TAG) .
45+
46+
docker-buildx-push: docker-buildx-builder ## Build and push multi-platform Docker image to registry
47+
docker buildx build --platform $(PLATFORMS) -f $(DOCKERFILE) -t $(IMAGE_NAME):$(TAG) --push .
48+
49+
docker-run: ## Run Docker container locally on specified PORT (default 8080)
50+
docker run --rm -it -p $(PORT):80 $(IMAGE_NAME):$(TAG)

‎docker/.dockerignore‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
node_modules
2+
build
3+
.docusaurus
4+
.cache-loader
5+
.git
6+
.github
7+
*.log
8+
.DS_Store
9+
.env*
10+
README.md
11+
LICENSE
12+
Makefile

‎docker/Dockerfile‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# syntax=docker/dockerfile:1
2+
3+
# ==========================================
4+
# Stage 1: Build static site with Node.js
5+
# ==========================================
6+
FROM --platform=$BUILDPLATFORM node:22-alpine AS builder
7+
8+
WORKDIR /app
9+
10+
# Install package dependencies
11+
COPY package*.json ./
12+
RUN npm ci
13+
14+
# Copy source files and build Docusaurus site
15+
COPY . .
16+
RUN npm run build
17+
18+
# ==========================================
19+
# Stage 2: Serve static files with Nginx
20+
# ==========================================
21+
FROM nginx:alpine AS runner
22+
23+
# Copy custom nginx configuration from docker directory
24+
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
25+
26+
# Copy compiled static assets from builder stage
27+
COPY --from=builder /app/build /usr/share/nginx/html
28+
29+
# Expose HTTP port
30+
EXPOSE 80
31+
32+
# Basic healthcheck
33+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
34+
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
35+
36+
# Start nginx in foreground
37+
CMD ["nginx", "-g", "daemon off;"]

‎docker/nginx.conf‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
server {
2+
listen 80;
3+
server_name localhost;
4+
5+
root /usr/share/nginx/html;
6+
index index.html index.htm;
7+
8+
# Gzip compression
9+
gzip on;
10+
gzip_vary on;
11+
gzip_proxied any;
12+
gzip_comp_level 6;
13+
gzip_types
14+
text/plain
15+
text/css
16+
text/xml
17+
text/javascript
18+
application/json
19+
application/javascript
20+
application/x-javascript
21+
application/xml
22+
application/xml+rss
23+
image/svg+xml;
24+
25+
# Single-page and nested route handling
26+
location / {
27+
try_files $uri $uri/ $uri.html /index.html =404;
28+
}
29+
30+
# Static assets caching
31+
location ~* \.(?:css|js|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico|webp)$ {
32+
expires 1y;
33+
add_header Cache-Control "public, max-age=31536000, immutable";
34+
access_log off;
35+
}
36+
37+
# Custom 404 page
38+
error_page 404 /404.html;
39+
location = /404.html {
40+
internal;
41+
}
42+
}

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"serve": "docusaurus serve",
1313
"write-translations": "docusaurus write-translations",
1414
"write-heading-ids": "docusaurus write-heading-ids",
15-
"test": "node scripts/test-docs.mjs"
15+
"test": "node scripts/test-docs.mjs && node scripts/test-infra.mjs"
1616
},
1717
"dependencies": {
1818
"@docusaurus/core": "^3.7.0",

‎scripts/test-infra.mjs‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import { execSync } from 'node:child_process';
5+
6+
const __filename = fileURLToPath(import.meta.url);
7+
const __dirname = path.dirname(__filename);
8+
const rootDir = path.resolve(__dirname, '..');
9+
10+
console.log('🧪 Starting Infrastructure & Docker Test Suite...\n');
11+
12+
let errorCount = 0;
13+
14+
function assert(condition, message) {
15+
if (!condition) {
16+
console.error(`❌ FAIL: ${message}`);
17+
errorCount++;
18+
} else {
19+
console.log(`✅ PASS: ${message}`);
20+
}
21+
}
22+
23+
// ==========================================
24+
// 1. Makefile Verification
25+
// ==========================================
26+
console.log('📋 Testing Makefile...');
27+
const makefilePath = path.join(rootDir, 'Makefile');
28+
assert(fs.existsSync(makefilePath), 'Makefile exists');
29+
30+
if (fs.existsSync(makefilePath)) {
31+
const makefileContent = fs.readFileSync(makefilePath, 'utf-8');
32+
const expectedTargets = [
33+
'all',
34+
'help',
35+
'install',
36+
'start',
37+
'build',
38+
'serve',
39+
'test',
40+
'clean',
41+
'docker-build',
42+
'docker-buildx-builder',
43+
'docker-buildx',
44+
'docker-buildx-push',
45+
'docker-run'
46+
];
47+
48+
for (const target of expectedTargets) {
49+
const hasTarget = new RegExp(`^${target}:`, 'm').test(makefileContent);
50+
assert(hasTarget, `Makefile defines target '${target}'`);
51+
}
52+
53+
assert(makefileContent.includes('docker/Dockerfile'), 'Makefile references docker/Dockerfile');
54+
55+
// Verify Makefile dry-run execution
56+
try {
57+
execSync('make -n test build docker-build docker-buildx', {
58+
cwd: rootDir,
59+
encoding: 'utf-8',
60+
stdio: 'pipe',
61+
});
62+
assert(true, 'Makefile dry-run (make -n) executes with valid syntax');
63+
} catch (err) {
64+
assert(false, `Makefile dry-run failed: ${err.message}`);
65+
}
66+
}
67+
68+
// ==========================================
69+
// 2. Docker Directory & Dockerfile Verification
70+
// ==========================================
71+
console.log('\n🐳 Testing Docker configs in docker/ directory...');
72+
const dockerDir = path.join(rootDir, 'docker');
73+
assert(fs.existsSync(dockerDir) && fs.statSync(dockerDir).isDirectory(), 'docker/ directory exists');
74+
75+
const dockerfilePath = path.join(dockerDir, 'Dockerfile');
76+
assert(fs.existsSync(dockerfilePath), 'docker/Dockerfile exists');
77+
78+
if (fs.existsSync(dockerfilePath)) {
79+
const dockerContent = fs.readFileSync(dockerfilePath, 'utf-8');
80+
81+
assert(dockerContent.includes('FROM --platform=$BUILDPLATFORM node:22-alpine AS builder') || dockerContent.includes('AS builder'), 'docker/Dockerfile contains builder stage');
82+
assert(dockerContent.includes('FROM nginx:alpine AS runner') || dockerContent.includes('FROM nginx:alpine'), 'docker/Dockerfile contains Nginx runner stage');
83+
assert(dockerContent.includes('npm run build'), 'docker/Dockerfile runs static build step');
84+
assert(dockerContent.includes('COPY docker/nginx.conf /etc/nginx/conf.d/default.conf'), 'docker/Dockerfile copies docker/nginx.conf to nginx conf.d');
85+
assert(dockerContent.includes('COPY --from=builder /app/build /usr/share/nginx/html'), 'docker/Dockerfile copies build artifacts to web root');
86+
assert(dockerContent.includes('EXPOSE 80'), 'docker/Dockerfile exposes port 80');
87+
assert(dockerContent.includes('HEALTHCHECK'), 'docker/Dockerfile defines container healthcheck');
88+
}
89+
90+
// ==========================================
91+
// 3. .dockerignore Verification
92+
// ==========================================
93+
console.log('\n🚫 Testing .dockerignore...');
94+
const dockerignoreInDocker = path.join(dockerDir, '.dockerignore');
95+
const dockerignoreInRoot = path.join(rootDir, '.dockerignore');
96+
assert(fs.existsSync(dockerignoreInDocker), 'docker/.dockerignore exists');
97+
assert(fs.existsSync(dockerignoreInRoot), '.dockerignore exists at root');
98+
99+
if (fs.existsSync(dockerignoreInDocker)) {
100+
const ignoreContent = fs.readFileSync(dockerignoreInDocker, 'utf-8');
101+
const requiredIgnores = ['node_modules', 'build', '.docusaurus', '.git'];
102+
for (const item of requiredIgnores) {
103+
assert(ignoreContent.includes(item), `docker/.dockerignore excludes '${item}'`);
104+
}
105+
}
106+
107+
// ==========================================
108+
// 4. Nginx Configuration Verification
109+
// ==========================================
110+
console.log('\n🌐 Testing docker/nginx.conf...');
111+
const nginxPath = path.join(dockerDir, 'nginx.conf');
112+
assert(fs.existsSync(nginxPath), 'docker/nginx.conf exists');
113+
114+
if (fs.existsSync(nginxPath)) {
115+
const nginxContent = fs.readFileSync(nginxPath, 'utf-8');
116+
assert(nginxContent.includes('listen 80;'), 'docker/nginx.conf listens on port 80');
117+
assert(nginxContent.includes('try_files'), 'docker/nginx.conf configures try_files for SPA routes');
118+
assert(nginxContent.includes('gzip on;'), 'docker/nginx.conf enables gzip compression');
119+
}
120+
121+
// ==========================================
122+
// 5. GitHub Actions Workflow Verification
123+
// ==========================================
124+
console.log('\n⚙️ Testing GitHub Actions Workflow...');
125+
const workflowPath = path.join(rootDir, '.github', 'workflows', 'ci.yml');
126+
assert(fs.existsSync(workflowPath), '.github/workflows/ci.yml exists');
127+
128+
if (fs.existsSync(workflowPath)) {
129+
const workflowContent = fs.readFileSync(workflowPath, 'utf-8');
130+
assert(workflowContent.includes('file: ./docker/Dockerfile'), 'CI workflow specifies file: ./docker/Dockerfile');
131+
assert(workflowContent.includes('platforms: linux/amd64,linux/arm64'), 'CI workflow specifies multi-platform: linux/amd64,linux/arm64');
132+
assert(workflowContent.includes('docker/setup-qemu-action'), 'CI workflow sets up QEMU');
133+
assert(workflowContent.includes('docker/setup-buildx-action'), 'CI workflow sets up Docker Buildx');
134+
assert(workflowContent.includes('docker/build-push-action'), 'CI workflow uses docker/build-push-action');
135+
assert(workflowContent.includes('ghcr.io'), 'CI workflow targets GitHub Container Registry');
136+
}
137+
138+
console.log('\n========================================');
139+
if (errorCount === 0) {
140+
console.log('🎉 ALL INFRASTRUCTURE TESTS PASSED CLEANLY!\n');
141+
process.exit(0);
142+
} else {
143+
console.error(`💥 ${errorCount} infrastructure test(s) failed.\n`);
144+
process.exit(1);
145+
}

0 commit comments

Comments
 (0)