From 6fa09d433ec58acdeb45d1d5458d0aae0bf352ae Mon Sep 17 00:00:00 2001 From: Alaashamel Date: Thu, 20 Aug 2026 02:38:21 +0300 Subject: [PATCH 1/5] api: hand-rolled Prometheus metrics endpoint Add a dependency-free metrics registry (counters, histograms, gauges) exposed at GET /metrics with Prometheus text exposition format 0.0.4. Middleware records http_requests_total by method/route/status and http_request_duration_seconds with cumulative buckets; process gauges (rss, heap, uptime) are refreshed on every scrape. --- apps/api/src/app.js | 10 +- apps/api/src/modules/metrics/middleware.js | 22 +++++ apps/api/src/modules/metrics/registry.js | 109 +++++++++++++++++++++ apps/api/src/modules/metrics/routes.js | 22 +++++ apps/api/test/metrics.test.js | 71 ++++++++++++++ 5 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/modules/metrics/middleware.js create mode 100644 apps/api/src/modules/metrics/registry.js create mode 100644 apps/api/src/modules/metrics/routes.js create mode 100644 apps/api/test/metrics.test.js diff --git a/apps/api/src/app.js b/apps/api/src/app.js index 05337ad..7ac9cc0 100644 --- a/apps/api/src/app.js +++ b/apps/api/src/app.js @@ -43,6 +43,9 @@ import { createChatService } from './modules/chat/service.js'; import { createChatRouter } from './modules/chat/routes.js'; import { createAiService } from './modules/ai/service.js'; import { createAiRouter, createAiArchiveRouter } from './modules/ai/routes.js'; +import { createMetricsRegistry } from './modules/metrics/registry.js'; +import { createMetricsMiddleware } from './modules/metrics/middleware.js'; +import { createMetricsRouter } from './modules/metrics/routes.js'; function buildDefaultAuth() { const accessTokens = createAccessTokenService({ @@ -119,7 +122,7 @@ export function buildDefaultModules({ }; } -export function createApp({ auth = null, modules = null, realtime = null } = {}) { +export function createApp({ auth = null, modules = null, realtime = null, metrics = null } = {}) { const app = express(); app.disable('x-powered-by'); @@ -131,6 +134,10 @@ export function createApp({ auth = null, modules = null, realtime = null } = {}) app.use(requestId()); app.use(requestLogger()); + const metricsRegistry = metrics?.registry ?? createMetricsRegistry(); + app.use(metrics?.middleware ?? createMetricsMiddleware(metricsRegistry)); + app.locals.metrics = metricsRegistry; + app.get('/', (_req, res) => { res.json({ service: 'devforge-api', @@ -275,6 +282,7 @@ export function createApp({ auth = null, modules = null, realtime = null } = {}) }), ); app.use('/api/v1/ai/archive', createAiArchiveRouter({ service: moduleBundle.ai })); + app.use('/metrics', createMetricsRouter({ registry: metricsRegistry })); app.use(notFoundHandler()); app.use(errorHandler()); diff --git a/apps/api/src/modules/metrics/middleware.js b/apps/api/src/modules/metrics/middleware.js new file mode 100644 index 0000000..c315dc6 --- /dev/null +++ b/apps/api/src/modules/metrics/middleware.js @@ -0,0 +1,22 @@ +/** + * Express middleware that records a `http_requests_total` counter and a + * `http_request_duration_seconds` histogram per (method, route, status). + * + * The route label is the matched pattern (e.g. `/api/v1/health/live` when the + * handler ran). Requests rejected before a handler ran (401/403/404) fall + * back to the mounted base path, or `unmatched`. + */ +export function createMetricsMiddleware(registry) { + return (req, res, next) => { + const start = process.hrtime.bigint(); + res.on('finish', () => { + const seconds = Number(process.hrtime.bigint() - start) / 1e9; + const matched = req.route?.path != null; + const route = matched ? `${req.baseUrl}${req.route.path}` : req.baseUrl || 'unmatched'; + const labels = { method: req.method, route, status: String(res.statusCode) }; + registry.inc('http_requests_total', labels); + registry.observe('http_request_duration_seconds', labels, seconds); + }); + next(); + }; +} diff --git a/apps/api/src/modules/metrics/registry.js b/apps/api/src/modules/metrics/registry.js new file mode 100644 index 0000000..006b77d --- /dev/null +++ b/apps/api/src/modules/metrics/registry.js @@ -0,0 +1,109 @@ +export const HTTP_REQUEST_DURATION_BUCKETS = [ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, +]; + +function labelKey(labels = {}) { + return JSON.stringify(labels, Object.keys(labels).sort()); +} + +function renderLabels(labels = {}) { + const keys = Object.keys(labels); + if (keys.length === 0) return ''; + const inner = keys + .sort() + .map( + (key) => + `${key}="${String(labels[key]).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`, + ) + .join(','); + return `{${inner}}`; +} + +function renderNumber(value) { + return Number.isInteger(value) ? String(value) : value.toFixed(3); +} + +/** + * Minimal Prometheus text-format registry with counters, histograms and + * gauges. Dependency-free on purpose so the API keeps a small footprint; + * the output is compatible with Prometheus text exposition format 0.0.4. + */ +export function createMetricsRegistry() { + const counters = new Map(); + const histograms = new Map(); + const gauges = new Map(); + + const registry = { + inc(name, labels, amount = 1) { + if (!counters.has(name)) counters.set(name, new Map()); + const byLabels = counters.get(name); + const key = labelKey(labels); + byLabels.set(key, (byLabels.get(key) ?? 0) + amount); + }, + + observe(name, labels, value) { + if (!histograms.has(name)) histograms.set(name, new Map()); + const byLabels = histograms.get(name); + const key = labelKey(labels); + const entry = + byLabels.get(key) ?? { + sum: 0, + count: 0, + buckets: new Array(HTTP_REQUEST_DURATION_BUCKETS.length).fill(0), + }; + entry.sum += value; + entry.count += 1; + for (let i = 0; i < HTTP_REQUEST_DURATION_BUCKETS.length; i += 1) { + if (value <= HTTP_REQUEST_DURATION_BUCKETS[i]) entry.buckets[i] += 1; + } + byLabels.set(key, entry); + }, + + setGauge(name, labels, value) { + if (!gauges.has(name)) gauges.set(name, new Map()); + gauges.get(name).set(labelKey(labels), value); + }, + + render() { + const lines = []; + const display = (key) => renderLabels(JSON.parse(key)); + + for (const [name, byLabels] of counters) { + lines.push(`# TYPE ${name} counter`); + for (const [key, value] of byLabels) { + lines.push(`${name}${display(key)} ${renderNumber(value)}`); + } + } + + for (const [name, byLabels] of histograms) { + lines.push(`# TYPE ${name} histogram`); + for (const [key, entry] of byLabels) { + const baseLabels = JSON.parse(key); + let cumulative = 0; + for (let i = 0; i < HTTP_REQUEST_DURATION_BUCKETS.length; i += 1) { + cumulative += entry.buckets[i]; + lines.push( + `${name}_bucket${renderLabels({ ...baseLabels, le: HTTP_REQUEST_DURATION_BUCKETS[i] })} ${cumulative}`, + ); + } + lines.push( + `${name}_bucket${renderLabels({ ...baseLabels, le: '+Inf' })} ${entry.count}`, + ); + lines.push(`${name}_sum${display(key)} ${entry.sum.toFixed(3)}`); + lines.push(`${name}_count${display(key)} ${entry.count}`); + } + } + + for (const [name, byLabels] of gauges) { + lines.push(`# TYPE ${name} gauge`); + for (const [key, value] of byLabels) { + lines.push(`${name}${display(key)} ${renderNumber(value)}`); + } + } + + return `${lines.join('\n')}\n`; + }, + }; + + return registry; +} diff --git a/apps/api/src/modules/metrics/routes.js b/apps/api/src/modules/metrics/routes.js new file mode 100644 index 0000000..bee23ee --- /dev/null +++ b/apps/api/src/modules/metrics/routes.js @@ -0,0 +1,22 @@ +import { Router } from 'express'; + +/** + * `GET /metrics` — Prometheus text exposition endpoint (no auth; ops probe). + * Refreshes process gauges on every scrape so numbers are always current. + */ +export function createMetricsRouter({ registry }) { + const router = Router(); + + router.get('/', (_req, res) => { + const memory = process.memoryUsage(); + registry.setGauge('process_uptime_seconds', {}, process.uptime()); + registry.setGauge('process_memory_rss_bytes', {}, memory.rss); + registry.setGauge('process_memory_heap_used_bytes', {}, memory.heapUsed); + registry.setGauge('process_memory_heap_total_bytes', {}, memory.heapTotal); + + res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); + res.send(registry.render()); + }); + + return router; +} diff --git a/apps/api/test/metrics.test.js b/apps/api/test/metrics.test.js new file mode 100644 index 0000000..5422ca3 --- /dev/null +++ b/apps/api/test/metrics.test.js @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import { createApp } from '../src/app.js'; + +let app; + +beforeEach(() => { + app = createApp(); +}); + +async function scrape() { + const res = await request(app).get('/metrics'); + return { res, body: res.text }; +} + +describe('GET /metrics', () => { + it('exposes a Prometheus text exposition payload', async () => { + await request(app).get('/api/v1/health/live'); + const { res, body } = await scrape(); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/^text\/plain/); + expect(body).toMatch(/# TYPE http_requests_total counter/); + expect(body).toMatch(/# TYPE http_request_duration_seconds histogram/); + expect(body).toMatch(/# TYPE process_uptime_seconds gauge/); + expect(body).toMatch(/process_uptime_seconds \d/); + expect(body).toMatch(/process_memory_rss_bytes \d+/); + }); + + it('records matched requests with their route pattern', async () => { + await request(app).get('/api/v1/health/live'); + const { body } = await scrape(); + expect(body).toContain( + 'http_requests_total{method="GET",route="/api/v1/health/live",status="200"} 1', + ); + expect(body).toMatch( + /http_request_duration_seconds_count\{.*route="\/api\/v1\/health\/live".*\} 1/, + ); + }); + + it('records unmatched requests as unmatched with status 404', async () => { + await request(app).get('/definitely-not-a-route'); + const { body } = await scrape(); + expect(body).toContain( + 'http_requests_total{method="GET",route="unmatched",status="404"} 1', + ); + }); + + it('produces cumulative histogram buckets ending in +Inf', async () => { + await request(app).get('/api/v1/health'); + const { body } = await scrape(); + expect(body).toMatch(/# TYPE http_request_duration_seconds histogram/); + const healthCountLine = body + .split('\n') + .find( + (line) => + line.includes('http_request_duration_seconds_count') && + line.includes('/api/v1/health'), + ); + expect(healthCountLine).toBeTruthy(); + expect(healthCountLine).toMatch(/ 1$/); + expect( + body + .split('\n') + .filter( + (line) => + line.startsWith('http_request_duration_seconds_bucket') && + line.includes('le="+Inf"'), + ), + ).not.toHaveLength(0); + }); +}); From 8144e3de42b5a8753d4c4ea534e9b3e2084f90dd Mon Sep 17 00:00:00 2001 From: Alaashamel Date: Thu, 20 Aug 2026 02:38:46 +0300 Subject: [PATCH 2/5] devops: Dockerfiles, Docker Compose full stack and nginx reverse proxy Add production-ready Dockerfiles for the API (multi-stage node:20-alpine with workspace pruning), web (vite build served by nginx:alpine) and AI service (python:3.13-slim). Replace the postgres-only docker-compose.yml with a full five-service stack (postgres, api, web, ai, nginx) with health checks, signed job token wiring and a reverse proxy that routes /api/, /metrics and /socket.io/ to their respective containers. --- .dockerignore | 17 ++++++ apps/ai/Dockerfile | 14 +++++ apps/api/Dockerfile | 22 ++++++++ apps/web/Dockerfile | 20 +++++++ docker-compose.yml | 92 +++++++++++++++++++++++++++++++++ infrastructure/nginx/Dockerfile | 6 +++ infrastructure/nginx/nginx.conf | 50 ++++++++++++++++++ 7 files changed, 221 insertions(+) create mode 100644 .dockerignore create mode 100644 apps/ai/Dockerfile create mode 100644 apps/api/Dockerfile create mode 100644 apps/web/Dockerfile create mode 100644 infrastructure/nginx/Dockerfile create mode 100644 infrastructure/nginx/nginx.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..65d5833 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules +**/node_modules +**/dist +**/.vite +.git +.github +.env +.env.* +!.env.example +*.log +.ruff_cache +**/__pycache__ +**/*.pyc +.coverage +coverage +tests/e2e +api-smoke.log diff --git a/apps/ai/Dockerfile b/apps/ai/Dockerfile new file mode 100644 index 0000000..558d9bc --- /dev/null +++ b/apps/ai/Dockerfile @@ -0,0 +1,14 @@ +# syntax=docker/dockerfile:1 +# DevForge AI service — Python/FastAPI (uvicorn). +# Build from the repository root: docker build -f apps/ai/Dockerfile . +FROM python:3.13-slim +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 +WORKDIR /app +COPY apps/ai/pyproject.toml apps/ai/README.md ./ +COPY apps/ai/app ./app +RUN pip install --no-cache-dir . && \ + useradd --create-home --uid 1001 ai +USER 1001 +EXPOSE 5001 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5001", "--app-dir", "/app"] diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..a347089 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1 +# DevForge API — Express service (depends on the @devforge/database workspace). +# Build from the repository root: docker build -f apps/api/Dockerfile . +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +COPY apps/api/package.json apps/api/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/database/package.json packages/database/package.json +RUN npm ci + +FROM node:20-alpine AS release +WORKDIR /app +ENV NODE_ENV=production +COPY --from=deps /app/node_modules ./node_modules +COPY apps ./apps +COPY packages ./packages +# Rebuild workspace links and drop dev dependencies from the runtime image. +RUN npm prune --omit=dev +USER node +EXPOSE 4000 +CMD ["node", "apps/api/src/server.js"] diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..271392f --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1 +# DevForge Web — Vite + React static site, served by nginx. +# Build from the repository root: docker build -f apps/web/Dockerfile . +# --build-arg VITE_API_URL=/api/v1 +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +COPY apps/api/package.json apps/api/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/database/package.json packages/database/package.json +RUN npm ci +COPY apps/web ./apps/web +ARG VITE_API_URL=/api/v1 +ENV VITE_API_URL=$VITE_API_URL +RUN npm run build -w apps/web + +FROM nginx:alpine AS serve +COPY --from=build /app/apps/web/dist /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker-compose.yml b/docker-compose.yml index 1671880..cf6dbe7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,11 @@ name: devforge +# Full local stack: postgres + api + web + ai + edge nginx. +# docker compose up --build +# then open http://localhost:8080 +# +# Production values (secrets) are read from the host environment; the dev +# defaults mirror .env.example. Never commit real secrets here. services: postgres: image: pgvector/pgvector:pg16 @@ -18,5 +24,91 @@ services: timeout: 3s retries: 10 + api: + build: + context: . + dockerfile: apps/api/Dockerfile + container_name: devforge-api + environment: + NODE_ENV: production + HOST: 0.0.0.0 + PORT: 4000 + LOG_LEVEL: ${LOG_LEVEL:-info} + DATABASE_URL: postgres://devforge:devforge@postgres:5432/devforge + CORS_ORIGINS: ${WEB_BASE_URL:-http://localhost:8080} + JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:-} + JWT_ACCESS_TTL: ${JWT_ACCESS_TTL:-15m} + REFRESH_TTL_DAYS: ${REFRESH_TTL_DAYS:-7} + WEB_BASE_URL: ${WEB_BASE_URL:-http://localhost:8080} + API_BASE_URL: ${API_BASE_URL:-http://localhost:8080} + GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} + GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} + GITHUB_OAUTH_CALLBACK_URL: ${GITHUB_OAUTH_CALLBACK_URL:-http://localhost:8080/api/v1/github/oauth/callback} + GITHUB_ENCRYPTION_KEY: ${GITHUB_ENCRYPTION_KEY:-} + AI_SERVICE_URL: http://ai:5001 + AI_JOB_SECRET: ${AI_JOB_SECRET:-} + AI_JOB_TTL_SECONDS: ${AI_JOB_TTL_SECONDS:-300} + AI_ARCHIVE_TTL_SECONDS: ${AI_ARCHIVE_TTL_SECONDS:-900} + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ['CMD-SHELL', 'node -e "fetch(\"http://localhost:4000/api/v1/health/ready\").then(r=>process.exit(r.status===200?0:1)).catch(()=>process.exit(1))"'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + + ai: + build: + context: . + dockerfile: apps/ai/Dockerfile + container_name: devforge-ai + environment: + AI_HOST: 0.0.0.0 + AI_PORT: 5001 + AI_LOG_LEVEL: ${LOG_LEVEL:-info} + AI_DATABASE_URL: postgres://devforge:devforge@postgres:5432/devforge + AI_JOB_SECRET: ${AI_JOB_SECRET:-} + AI_JOB_TOKEN_TTL_SECONDS: ${AI_JOB_TTL_SECONDS:-300} + AI_PRIMARY_PROVIDER: ${AI_PRIMARY_PROVIDER:-local} + AI_FALLBACK_PROVIDER: ${AI_FALLBACK_PROVIDER:-} + AI_OPENAI_API_KEY: ${AI_OPENAI_API_KEY:-} + AI_ANTHROPIC_API_KEY: ${AI_ANTHROPIC_API_KEY:-} + AI_EMBEDDING_PROVIDER: ${AI_EMBEDDING_PROVIDER:-local} + AI_LOCAL_MODEL_URL: ${AI_LOCAL_MODEL_URL:-http://host.docker.internal:11434/v1} + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ['CMD-SHELL', 'python -c "import urllib.request; urllib.request.urlopen(\"http://localhost:5001/health\", timeout=3)"'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + + web: + build: + context: . + dockerfile: apps/web/Dockerfile + args: + VITE_API_URL: /api/v1 + container_name: devforge-web + depends_on: + - api + + nginx: + build: + context: infrastructure/nginx + dockerfile: Dockerfile + container_name: devforge-nginx + ports: + - '${HTTP_PORT:-8080}:80' + depends_on: + api: + condition: service_healthy + web: + condition: service_started + volumes: pgdata: diff --git a/infrastructure/nginx/Dockerfile b/infrastructure/nginx/Dockerfile new file mode 100644 index 0000000..e7b3f36 --- /dev/null +++ b/infrastructure/nginx/Dockerfile @@ -0,0 +1,6 @@ +# syntax=docker/dockerfile:1 +# DevForge edge nginx — reverse proxy + static entry point. +# Build from the infrastructure/nginx directory (or with -f from repo root). +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/infrastructure/nginx/nginx.conf b/infrastructure/nginx/nginx.conf new file mode 100644 index 0000000..93f1413 --- /dev/null +++ b/infrastructure/nginx/nginx.conf @@ -0,0 +1,50 @@ +# DevForge edge reverse proxy. +# / -> web (static SPA) +# /api/ -> api (REST) +# /socket.io/ -> api (realtime websocket upgrade) +# /metrics -> api (Prometheus scrape) +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA fallback: serve index.html for client-side routes. + location / { + proxy_pass http://web:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + proxy_pass http://api:4000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /metrics { + proxy_pass http://api:4000/metrics; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Socket.io realtime — needs HTTP/1.1 + upgrade headers. + location /socket.io/ { + proxy_pass http://api:4000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; + } +} From b443cfeb536fc35073489a620b678091851cdb19 Mon Sep 17 00:00:00 2001 From: Alaashamel Date: Thu, 20 Aug 2026 02:39:06 +0300 Subject: [PATCH 3/5] ci: security audit and Docker build validation workflows Add security.yml (weekly + PR): npm audit --audit-level=high and pip-audit on the AI service. Add docker.yml: builds all four images (api, web, ai, nginx) on push/PR to validate Dockerfiles. --- .github/workflows/docker.yml | 26 +++++++++++++++++++++ .github/workflows/security.yml | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/security.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..5eed8ca --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,26 @@ +name: Docker Build + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + name: Build images + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build API image + run: docker build -f apps/api/Dockerfile -t devforge-api:ci . + + - name: Build Web image + run: docker build -f apps/web/Dockerfile -t devforge-web:ci . + + - name: Build AI image + run: docker build -f apps/ai/Dockerfile -t devforge-ai:ci . + + - name: Build nginx image + run: docker build infrastructure/nginx -t devforge-nginx:ci . diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..ab7c17d --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,42 @@ +name: Security Audit + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 6 * * 1' + +jobs: + audit: + name: Dependency audit + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install npm dependencies + run: npm ci + + - name: Audit npm dependencies + run: npm audit --audit-level=high + + - name: Install AI service dependencies + run: python -m pip install -e "./apps/ai[dev]" + + - name: Install pip-audit + run: python -m pip install pip-audit + + - name: Audit Python dependencies + run: python -m pip_audit From 9e3bfe9f90548e01210005a9d4b237c019c02561 Mon Sep 17 00:00:00 2001 From: Alaashamel Date: Thu, 20 Aug 2026 02:39:49 +0300 Subject: [PATCH 4/5] ai: pin wheel>=0.46.2 to resolve CVE-2026-24049 Add wheel>=0.46.2 to the AI service dev extras so fresh CI environments pick up the patched build dependency. --- apps/ai/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ai/pyproject.toml b/apps/ai/pyproject.toml index ddb62eb..ceb870d 100644 --- a/apps/ai/pyproject.toml +++ b/apps/ai/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ dev = [ "pytest>=8.2,<9.0", "ruff>=0.6,<1.0", + "wheel>=0.46.2", ] [build-system] From a8af7b28527cf55cbb731b556d8f6d51909de1a3 Mon Sep 17 00:00:00 2001 From: Alaashamel Date: Thu, 20 Aug 2026 02:44:38 +0300 Subject: [PATCH 5/5] fix(ci): pin pytest>=9.0.3 for PYSEC-2026-1845 and fix nginx docker build syntax --- .github/workflows/docker.yml | 2 +- apps/ai/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5eed8ca..c0a255e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -23,4 +23,4 @@ jobs: run: docker build -f apps/ai/Dockerfile -t devforge-ai:ci . - name: Build nginx image - run: docker build infrastructure/nginx -t devforge-nginx:ci . + run: docker build infrastructure/nginx -t devforge-nginx:ci diff --git a/apps/ai/pyproject.toml b/apps/ai/pyproject.toml index ceb870d..ad9f272 100644 --- a/apps/ai/pyproject.toml +++ b/apps/ai/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest>=8.2,<9.0", + "pytest>=9.0.3", "ruff>=0.6,<1.0", "wheel>=0.46.2", ]