Add Woodpecker CI/CD pipeline configuration and new chart components

Introduce a Woodpecker CI/CD pipeline in `.woodpecker.yml` for automated deployment on pushes to the main branch. Add new components for visualizing campaign performance, including `DailyPerformanceChart`, `InboxPlacementChart`, and `InboxVsSpamChart`, to enhance the Campaign Detail page. Update `WarmupPlanChart` to include additional metrics and improve tooltip information. Enhance utility functions for chart data formatting and daily statistics mapping.
This commit is contained in:
Jonny Nguyen 2026-06-26 14:23:18 -07:00
parent 5b2eee84be
commit ed3ec86025
11 changed files with 794 additions and 55 deletions

32
.woodpecker.yml Normal file
View file

@ -0,0 +1,32 @@
# Woodpecker CI — Forgejo at https://git.jonnyn.com/
# Push to main → verify build → deploy on home server.
#
# Server prerequisites: see docs/deploy-ci-cd.md
when:
- event: push
branch: main
steps:
verify:
image: node:22-bookworm-slim
environment:
DATABASE_URL: file:/tmp/warmbox-ci.db
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
NODE_ENV: test
commands:
- npm ci
- npx prisma generate
- npm run build
- npm test
deploy:
image: docker:27-cli
depends_on: [verify]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /srv/docker-configs/warmbox:/deploy
commands:
- apk add --no-cache git bash
- chmod +x /deploy/scripts/deploy.sh
- DEPLOY_DIR=/deploy DEPLOY_BRANCH=main bash /deploy/scripts/deploy.sh

View file

@ -0,0 +1,98 @@
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { DailyChartRow } from '../lib/campaign-chart-utils';
function PerformanceTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: DailyChartRow; name?: string; value?: number; color?: string }>;
}) {
if (!active || !payload?.length) return null;
const row = payload[0]!.payload;
return (
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-lg text-sm min-w-[200px]">
<p className="font-semibold text-slate-900">{row.label}</p>
<p className="text-xs text-slate-500 mb-2">Day {row.dayIndex}</p>
<dl className="space-y-1">
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Scheduled</dt>
<dd className="font-medium tabular-nums">{row.plannedSends}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-indigo-600">Sent</dt>
<dd className="font-medium tabular-nums text-indigo-600">{row.sends}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-emerald-600">Replied</dt>
<dd className="font-medium tabular-nums text-emerald-600">{row.replies}</dd>
</div>
</dl>
</div>
);
}
type DailyPerformanceChartProps = {
data: DailyChartRow[];
};
export function DailyPerformanceChart({ data }: DailyPerformanceChartProps) {
if (data.length === 0) {
return (
<p className="text-sm text-slate-500 py-8 text-center">
No activity in the last 7 days yet.
</p>
);
}
return (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} width={32} />
<Tooltip content={<PerformanceTooltip />} />
<Legend />
<Line
type="monotone"
dataKey="plannedSends"
name="Scheduled"
stroke="#94a3b8"
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
/>
<Line
type="monotone"
dataKey="sends"
name="Sent"
stroke="#4f46e5"
strokeWidth={2}
dot={{ r: 3, fill: '#4f46e5' }}
activeDot={{ r: 5 }}
/>
<Line
type="monotone"
dataKey="replies"
name="Replied"
stroke="#ec4899"
strokeWidth={2}
dot={{ r: 3, fill: '#ec4899' }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -0,0 +1,88 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { DailyChartRow } from '../lib/campaign-chart-utils';
import { formatPercent } from '../lib/utils';
function PlacementBarTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: DailyChartRow }>;
}) {
if (!active || !payload?.length) return null;
const row = payload[0]!.payload;
const total = row.inbox + row.spam;
const inboxRate = total > 0 ? (row.inbox / total) * 100 : 100;
return (
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-lg text-sm min-w-[200px]">
<p className="font-semibold text-slate-900">{row.label}</p>
<p className="text-xs text-slate-500 mb-2">Day {row.dayIndex}</p>
<dl className="space-y-1">
<div className="flex justify-between gap-4">
<dt className="text-emerald-700">Inbox</dt>
<dd className="font-medium tabular-nums text-emerald-700">{row.inbox}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-red-600">Spam</dt>
<dd className="font-medium tabular-nums text-red-600">{row.spam}</dd>
</div>
<div className="flex justify-between gap-4 pt-1 border-t border-slate-100">
<dt className="text-slate-500">Inbox rate</dt>
<dd className="font-medium tabular-nums">{formatPercent(inboxRate)}</dd>
</div>
</dl>
</div>
);
}
type InboxPlacementChartProps = {
data: DailyChartRow[];
};
export function InboxPlacementChart({ data }: InboxPlacementChartProps) {
if (data.length === 0) {
return (
<p className="text-sm text-slate-500 py-8 text-center">
No placement data in the last 7 days yet.
</p>
);
}
return (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} width={32} />
<Tooltip content={<PlacementBarTooltip />} />
<Legend />
<Bar
dataKey="inbox"
name="Inbox"
stackId="placement"
fill="#10b981"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="spam"
name="Spam"
stackId="placement"
fill="#ef4444"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -0,0 +1,114 @@
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import { formatPercent } from '../lib/utils';
const INBOX_COLOR = '#1e3a5f';
const SPAM_COLOR = '#b91c1c';
type PlacementSlice = {
name: string;
value: number;
percent: number;
color: string;
};
type InboxVsSpamChartProps = {
inboxCount: number;
spamCount: number;
};
function PlacementTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: PlacementSlice }>;
}) {
if (!active || !payload?.length) return null;
const slice = payload[0]!.payload;
return (
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2 shadow-lg text-sm">
<p className="font-medium" style={{ color: slice.color }}>
{slice.name}
</p>
<p className="tabular-nums text-slate-600">
{slice.value} emails · {formatPercent(slice.percent)}
</p>
</div>
);
}
export function InboxVsSpamChart({ inboxCount, spamCount }: InboxVsSpamChartProps) {
const total = inboxCount + spamCount;
if (total === 0) {
return (
<p className="text-sm text-slate-500 py-12 text-center">
No warmup emails received yet placement appears after the first sends land in the
primary inbox.
</p>
);
}
const inboxPercent = (inboxCount / total) * 100;
const spamPercent = (spamCount / total) * 100;
const data: PlacementSlice[] = [
{
name: 'In Inbox',
value: inboxCount,
percent: inboxPercent,
color: INBOX_COLOR,
},
{
name: 'In Spam',
value: spamCount,
percent: spamPercent,
color: SPAM_COLOR,
},
].filter((slice) => slice.value > 0);
return (
<div className="h-72 flex flex-col">
<div className="flex flex-wrap justify-center gap-x-6 gap-y-1 text-sm mb-2">
<span className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full" style={{ background: INBOX_COLOR }} />
<span className="text-slate-700">
In Inbox <strong className="tabular-nums">{formatPercent(inboxPercent)}</strong>
</span>
</span>
<span className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full" style={{ background: SPAM_COLOR }} />
<span className="text-slate-700">
In Spam <strong className="tabular-nums">{formatPercent(spamPercent)}</strong>
</span>
</span>
</div>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius="58%"
outerRadius="82%"
paddingAngle={data.length > 1 ? 2 : 0}
strokeWidth={2}
stroke="#fff"
>
{data.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip content={<PlacementTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
<p className="text-center text-xs text-slate-400 mt-1 tabular-nums">
{total} warmup {total === 1 ? 'email' : 'emails'} tracked
</p>
</div>
);
}

View file

@ -2,6 +2,7 @@ import {
Bar,
CartesianGrid,
ComposedChart,
LabelList,
Legend,
Line,
ReferenceLine,
@ -10,7 +11,8 @@ import {
XAxis,
YAxis,
} from 'recharts';
import type { CampaignWarmupPlan, CampaignWarmupPlanDay } from '@warmbox/shared';
import type { CampaignWarmupPlan } from '@warmbox/shared';
import { formatChartDate } from '../lib/campaign-chart-utils';
type ChartRow = {
dayIndex: number;
@ -20,26 +22,41 @@ type ChartRow = {
isFuture: boolean;
plannedSends: number;
plannedReplies: number;
actualSends: number;
actualReplies: number;
spamDetected: number;
inboxDelivered: number;
spamDetected: number;
scheduledRemainder: number;
actualReplies: number;
barTotal: number;
};
function toChartRows(plan: CampaignWarmupPlan): ChartRow[] {
return plan.days.map((day) => ({
return plan.days.map((day) => {
const inboxDelivered = day.isFuture
? 0
: Math.max(0, day.actual.sends - day.actual.spamDetected);
const spamDetected = day.isFuture ? 0 : day.actual.spamDetected;
const actualTotal = inboxDelivered + spamDetected;
const scheduledRemainder = day.isFuture
? day.planned.sends
: Math.max(0, day.planned.sends - actualTotal);
const barTotal = day.isFuture ? day.planned.sends : Math.max(day.planned.sends, actualTotal);
return {
dayIndex: day.dayIndex,
label: `D${day.dayIndex}`,
label: day.date ? formatChartDate(day.date) : `D${day.dayIndex}`,
date: day.date,
isToday: day.isToday,
isFuture: day.isFuture,
plannedSends: day.planned.sends,
plannedReplies: day.planned.replies,
actualSends: day.actual.sends,
actualReplies: day.actual.replies,
spamDetected: day.actual.spamDetected,
inboxDelivered: Math.max(0, day.actual.sends - day.actual.spamDetected),
}));
inboxDelivered,
spamDetected,
scheduledRemainder,
actualReplies: day.isFuture ? 0 : day.actual.replies,
barTotal,
};
});
}
function PlanTooltip({
@ -52,12 +69,13 @@ function PlanTooltip({
if (!active || !payload?.length) return null;
const row = payload[0]!.payload;
const sent = row.inboxDelivered + row.spamDetected;
return (
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-lg text-sm min-w-[220px]">
<p className="font-semibold text-slate-900">
Day {row.dayIndex}
{row.date ? ` · ${row.date}` : ''}
{row.date ? ` · ${row.label}` : ''}
</p>
{row.isToday && <p className="text-xs text-indigo-600 font-medium mt-0.5">Today</p>}
{row.isFuture && <p className="text-xs text-slate-400 mt-0.5">Upcoming</p>}
@ -74,11 +92,11 @@ function PlanTooltip({
<>
<div className="flex justify-between gap-6">
<dt className="text-slate-500">Sent</dt>
<dd className="font-medium tabular-nums text-indigo-600">{row.actualSends}</dd>
<dd className="font-medium tabular-nums text-indigo-700">{sent}</dd>
</div>
<div className="flex justify-between gap-6">
<dt className="text-slate-500">Replied</dt>
<dd className="font-medium tabular-nums text-emerald-600">{row.actualReplies}</dd>
<dd className="font-medium tabular-nums text-pink-600">{row.actualReplies}</dd>
</div>
<div className="flex justify-between gap-6">
<dt className="text-slate-500">In spam</dt>
@ -86,7 +104,7 @@ function PlanTooltip({
</div>
<div className="flex justify-between gap-6">
<dt className="text-slate-500">Inbox</dt>
<dd className="font-medium tabular-nums">{row.inboxDelivered}</dd>
<dd className="font-medium tabular-nums text-emerald-700">{row.inboxDelivered}</dd>
</div>
</>
)}
@ -114,14 +132,14 @@ export function WarmupPlanChart({ plan }: WarmupPlanChartProps) {
return (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<ComposedChart data={data} margin={{ top: 20, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis
dataKey="label"
tick={{ fontSize: 11 }}
interval={plan.durationDays > 20 ? Math.floor(plan.durationDays / 10) : 0}
/>
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} width={32} />
<Tooltip content={<PlanTooltip />} />
<Legend />
{todayRow && (
@ -132,22 +150,45 @@ export function WarmupPlanChart({ plan }: WarmupPlanChartProps) {
label={{ value: 'Today', position: 'top', fill: '#4f46e5', fontSize: 11 }}
/>
)}
<Bar
dataKey="inboxDelivered"
stackId="volume"
fill="#1e3a5f"
name="Sent"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="spamDetected"
stackId="volume"
fill="#b91c1c"
name="In spam"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="scheduledRemainder"
stackId="volume"
fill="#cbd5e1"
name="Scheduled"
radius={[4, 4, 0, 0]}
>
<LabelList
dataKey="barTotal"
position="top"
className="fill-slate-600 text-[10px] font-medium"
formatter={(value: number) => (value > 0 ? String(value) : '')}
/>
</Bar>
<Line
type="monotone"
dataKey="plannedSends"
stroke="#94a3b8"
dataKey="actualReplies"
name="Replies"
stroke="#ec4899"
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
name="Scheduled"
dot={{ r: 4, fill: '#ec4899', strokeWidth: 0 }}
activeDot={{ r: 6 }}
/>
<Bar dataKey="actualSends" fill="#4f46e5" name="Sent" radius={[2, 2, 0, 0]} />
<Bar dataKey="actualReplies" fill="#10b981" name="Replied" radius={[2, 2, 0, 0]} />
<Bar dataKey="spamDetected" fill="#ef4444" name="In spam" radius={[2, 2, 0, 0]} />
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
export type { CampaignWarmupPlanDay };

View file

@ -0,0 +1,41 @@
import type { CampaignStatsToday } from '@warmbox/shared';
export function formatChartDate(iso: string): string {
const d = new Date(`${iso}T12:00:00`);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export function last7DaysIsoRange(): { from: string; to: string } {
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 6);
const toIso = to.toISOString().slice(0, 10);
const fromIso = from.toISOString().slice(0, 10);
return { from: fromIso, to: toIso };
}
export type DailyChartRow = {
dayIndex: number;
label: string;
date: string;
plannedSends: number;
plannedReplies: number;
sends: number;
replies: number;
spam: number;
inbox: number;
};
export function mapDailyStatToChartRow(stat: CampaignStatsToday): DailyChartRow {
return {
dayIndex: stat.dayIndex,
label: formatChartDate(stat.date),
date: stat.date,
plannedSends: stat.planned.sends,
plannedReplies: stat.planned.replies,
sends: stat.actual.sends,
replies: stat.actual.replies,
spam: stat.actual.spamDetected,
inbox: Math.max(0, stat.actual.sends - stat.actual.spamDetected),
};
}

View file

@ -97,6 +97,22 @@ export type Campaign = {
export type DailyStat = CampaignStatsToday;
export type CampaignStatsSummary = {
campaignId: string;
range: '7d' | '30d' | 'all';
totals: {
plannedSends: number;
plannedReplies: number;
sends: number;
replies: number;
spamDetected: number;
spamRescued: number;
};
rates: { reply: number; inbox: number };
onTrack: boolean;
days: number;
};
export type WarmupLog = {
id: string;
campaignId: string | null;

View file

@ -7,6 +7,7 @@ import { invalidateCampaignCaches, invalidateCampaignListCaches } from '../lib/q
import type {
Campaign,
CampaignSatellite,
CampaignStatsSummary,
CampaignStatsToday,
CampaignWarmupPlan,
ComplianceReport,
@ -18,6 +19,10 @@ import { StatusBadge } from '../components/StatusBadge';
import { MetricCard } from '../components/MetricCard';
import { ComplianceChecklist } from '../components/ComplianceChecklist';
import { WarmupPlanChart } from '../components/WarmupPlanChart';
import { InboxVsSpamChart } from '../components/InboxVsSpamChart';
import { DailyPerformanceChart } from '../components/DailyPerformanceChart';
import { InboxPlacementChart } from '../components/InboxPlacementChart';
import { last7DaysIsoRange, mapDailyStatToChartRow } from '../lib/campaign-chart-utils';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/Tabs';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card';
import { Skeleton } from '../components/ui/Skeleton';
@ -78,6 +83,31 @@ export function CampaignDetailPage() {
refetchInterval: 30_000,
});
const { data: daily7d } = useQuery({
queryKey: ['campaign', id, 'stats', 'daily', '7d'],
queryFn: () => {
const { from, to } = last7DaysIsoRange();
return apiGet<{ data: CampaignStatsToday[] }>(
`/api/campaigns/${id}/stats/daily?from=${from}&to=${to}`,
);
},
enabled: !!id,
refetchInterval: 30_000,
});
const { data: placementSummary } = useQuery({
queryKey: ['campaign', id, 'stats', 'all'],
queryFn: () => apiGet<CampaignStatsSummary>(`/api/campaigns/${id}/stats?range=all`),
enabled: !!id,
refetchInterval: 30_000,
});
const dailyChartData = daily7d?.data.map(mapDailyStatToChartRow) ?? [];
const inboxCount = placementSummary
? Math.max(0, placementSummary.totals.sends - placementSummary.totals.spamDetected)
: 0;
const spamCount = placementSummary?.totals.spamDetected ?? 0;
const { data: logs } = useQuery({
queryKey: ['logs', { campaignId: id }],
queryFn: () =>
@ -314,12 +344,13 @@ export function CampaignDetailPage() {
/>
</div>
<Card>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Warmup Plan</CardTitle>
<CardTitle>Inbox Warming Plan</CardTitle>
<p className="text-sm text-slate-500 font-normal mt-1">
Full {campaign.durationDays}-day schedule with live progress. Hover a day for
scheduled vs sent, replied, and spam counts. Updates every 30 seconds.
Full {campaign.durationDays}-day schedule scheduled volume, live sends, spam,
and replies. Hover any day for details. Updates every 30 seconds.
</p>
</CardHeader>
<CardContent>
@ -338,6 +369,47 @@ export function CampaignDetailPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Inbox vs Spam</CardTitle>
<p className="text-sm text-slate-500 font-normal mt-1">
Where warmup emails landed in the primary mailbox for this campaign.
</p>
</CardHeader>
<CardContent>
<InboxVsSpamChart inboxCount={inboxCount} spamCount={spamCount} />
</CardContent>
</Card>
</div>
{campaign.currentDay > 0 && (
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Daily Performance</CardTitle>
<p className="text-sm text-slate-500 font-normal mt-1">
Last 7 days scheduled vs sent vs replied volume.
</p>
</CardHeader>
<CardContent>
<DailyPerformanceChart data={dailyChartData} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Inbox Placement (7d)</CardTitle>
<p className="text-sm text-slate-500 font-normal mt-1">
Last 7 days inbox vs spam count per day.
</p>
</CardHeader>
<CardContent>
<InboxPlacementChart data={dailyChartData} />
</CardContent>
</Card>
</div>
)}
<Card>
<CardHeader>
<CardTitle>Campaign Details</CardTitle>

183
docs/deploy-ci-cd.md Normal file
View file

@ -0,0 +1,183 @@
# CI/CD with Woodpecker (Forgejo)
Automate deploys to your home server when you push to **`main`** on [Forgejo](https://git.jonnyn.com/).
```text
git push main → Forgejo webhook → Woodpecker → verify (build/test) → deploy.sh → docker compose
```
Woodpecker is the right choice here — it runs on your server next to the app, can use the Docker socket, and integrates natively with Forgejo. GitHub Actions would require SSH secrets and extra wiring for a self-hosted Forgejo repo.
---
## Overview
| Piece | Role |
|-------|------|
| **Forgejo** (`git.jonnyn.com`) | Git remote, sends webhooks on push |
| **Woodpecker** | Runs `.woodpecker.yml` pipeline |
| **`scripts/deploy.sh`** | `git pull` + `docker compose down` + `up --build` on server |
| **`/srv/docker-configs/warmbox`** | Live deploy directory (must stay a git clone) |
---
## 1. One-time server setup
### Deploy directory is a git clone
Your live app should already be:
```bash
cd /srv/docker-configs/warmbox
git remote -v
# origin https://git.jonnyn.com/you/warmbox.git (or SSH URL)
```
If you copied files without git, re-clone:
```bash
mv /srv/docker-configs/warmbox /srv/docker-configs/warmbox.bak
git clone https://git.jonnyn.com/YOU/warmbox.git /srv/docker-configs/warmbox
cp /srv/docker-configs/warmbox.bak/.env /srv/docker-configs/warmbox/
# restore warmbox-data volume is unchanged — docker volume persists
```
Ensure `git pull` works without a password (deploy key or credential helper):
```bash
cd /srv/docker-configs/warmbox
git pull origin main
```
### Make deploy script executable
```bash
chmod +x /srv/docker-configs/warmbox/scripts/deploy.sh
```
### Woodpecker agent: allow Docker socket + deploy path
The **deploy** step mounts:
- `/var/run/docker.sock` — run `docker compose` from CI
- `/srv/docker-configs/warmbox` — your live checkout
In your **Woodpecker agent** container environment (docker-compose or stack config), allow pipeline volumes. Example:
```yaml
# woodpecker-agent service (adjust to your stack)
environment:
WOODPECKER_VOLUMES_ALLOWED: /var/run/docker.sock,/srv/docker-configs/warmbox
```
Alternatively, in the Woodpecker UI → your **warmbox** repository → enable **Trusted** (allows volume mounts for that repo).
The agent also needs the Docker socket mounted (you likely already have this):
```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
If your deploy path differs from `/srv/docker-configs/warmbox`, edit:
- `.woodpecker.yml``deploy` step volume path
- Or set a symlink
---
## 2. Connect Forgejo to Woodpecker
These steps are in the Woodpecker UI (URL depends on your install, often `https://ci.jonnyn.com` or similar).
1. **Forgejo OAuth app** (Forgejo → Settings → Applications → Create OAuth2 Application)
- Redirect URI: `https://<woodpecker-host>/authorize`
2. **Woodpecker server** — add Forgejo host `https://git.jonnyn.com` with OAuth client ID/secret
3. **Login to Woodpecker** with Forgejo account
4. **Activate** the `warmbox` repository
5. Enable **Trusted** on the repo (if not using `WOODPECKER_VOLUMES_ALLOWED`)
6. Confirm webhook exists on Forgejo (repo → Settings → Webhooks)
---
## 3. Pipeline file (in repo)
Already included at repo root:
**`.woodpecker.yml`**
1. **`verify`** — `npm ci`, `prisma generate`, `build`, `test` (runs in Woodpecker workspace clone)
2. **`deploy`** — runs `scripts/deploy.sh` in `/srv/docker-configs/warmbox`
Push this file to `main`, then Woodpecker picks it up automatically.
---
## 4. First CI deploy
```bash
# On your Mac
git add .woodpecker.yml scripts/deploy.sh docs/deploy-ci-cd.md
git commit -m "Add Woodpecker CI/CD deploy pipeline"
git push origin main
```
Watch the pipeline in Woodpecker UI. On success:
```bash
curl -s https://warmbox.jonnyn.com/health
```
---
## 5. Manual deploy (without CI)
```bash
ssh burke
cd /srv/docker-configs/warmbox
./scripts/deploy.sh
```
---
## 6. Customize deploy path
If not using `/srv/docker-configs/warmbox`, edit `.woodpecker.yml`:
```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /your/path/warmbox:/deploy
```
---
## Troubleshooting
| Symptom | Fix |
|---------|-----|
| Pipeline not triggered | Check Forgejo webhook deliveries; repo activated in Woodpecker |
| `volume not allowed` | Trusted repo or `WOODPECKER_VOLUMES_ALLOWED` on agent |
| `permission denied` docker.sock | Agent container must mount `/var/run/docker.sock` |
| `git fetch` fails in deploy | Fix git credentials in `/srv/docker-configs/warmbox` |
| `.env missing` | Create `.env` on server (never commit it); CI does not copy `.env` |
| `verify` fails | Fix tests locally; push again |
| `deploy` health check fails | `docker compose logs warmbox` on server |
| Wrong branch | Default is `main`; set `DEPLOY_BRANCH` in deploy step if needed |
### Brief downtime
`docker compose down` stops Warmbox for the length of the rebuild (usually 13 minutes). Cron jobs pause during that window — acceptable for homelab deploys.
### Secrets
- **Do not** commit `.env` — stays only on server
- Woodpecker does not need `ENCRYPTION_KEY` or `API_KEY` for deploy (only `verify` uses a dummy key)
---
## Optional: deploy only (skip tests)
For faster iteration, comment out the `verify` step and remove `depends_on` from `deploy` in `.woodpecker.yml`. Not recommended for `main` long term.
See also [deploy-ubuntu-docker.md](./deploy-ubuntu-docker.md).

View file

@ -221,3 +221,9 @@ Common causes: missing `ENCRYPTION_KEY`, bad `DATABASE_URL`, migration error.
NPM and Warmbox must share a Docker network so NPM can resolve the hostname `warmbox`. A separate `warmbox_internal` network is unnecessary for this setup — one external network is enough.
See also [self-host-hardening.md](./self-host-hardening.md).
---
## CI/CD (Woodpecker)
Push to `main` on Forgejo → automatic deploy. See **[deploy-ci-cd.md](./deploy-ci-cd.md)**.

48
scripts/deploy.sh Executable file
View file

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Production deploy — run on the server in the git checkout (e.g. /srv/docker-configs/warmbox).
# Used by Woodpecker CI and manual deploys.
set -euo pipefail
DEPLOY_DIR="${DEPLOY_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
BRANCH="${DEPLOY_BRANCH:-main}"
cd "$DEPLOY_DIR"
echo "==> Deploying Warmbox from ${DEPLOY_DIR} (branch ${BRANCH})"
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "ERROR: ${DEPLOY_DIR} is not a git repository"
exit 1
fi
git fetch origin "$BRANCH"
git reset --hard "origin/${BRANCH}"
if [[ ! -f .env ]]; then
echo "ERROR: .env missing in ${DEPLOY_DIR}. Create it from .env.example before deploying."
exit 1
fi
echo "==> Stopping containers..."
docker compose down
echo "==> Building and starting..."
docker compose up -d --build
echo "==> Waiting for container health..."
for i in $(seq 1 30); do
if docker compose exec -T warmbox node -e \
"fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" \
>/dev/null 2>&1; then
echo "==> Health check passed"
docker compose ps
echo "==> Deploy complete"
exit 0
fi
sleep 2
done
echo "==> Health check failed"
docker compose ps
docker compose logs --tail=80 warmbox
exit 1