docs(sprint-1-plan, chunk 3/4): W4 frontend, W5 seed data, W6 deploy + CI/CD
@@ -0,0 +1,474 @@
|
|||||||
|
# Sprint 1 — Plan, Part 3 ("Spark")
|
||||||
|
|
||||||
|
_Continued from [Sprint-1-Plan-Part-2](Sprint-1-Plan-Part-2.md). Covers W4 (frontend), W5 (seed data), W6 (deploy + CI/CD)._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### W4 — Frontend
|
||||||
|
|
||||||
|
**Goal:** the four humans can see the idea list and post a new idea. PWA manifest is served and "Add to Home Screen" works on iOS Safari + Android Chrome. Service worker registers (stub-only, no caching yet).
|
||||||
|
|
||||||
|
**Pre-requisite:** W3 complete (`/api/ideas` works locally against a logged-in proxy session).
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
|
||||||
|
1. [`frontend/app/layout.tsx`](../frontend/app/layout.tsx) — root layout, fonts, theme colour, link to manifest.
|
||||||
|
2. [`frontend/app/(app)/layout.tsx`](../frontend/app/(app)/layout.tsx) — protected layout: redirects to `/login` if no session.
|
||||||
|
3. [`frontend/app/(app)/page.tsx`](../frontend/app/(app)/page.tsx) — `/` redirects to `/ideas`.
|
||||||
|
4. [`frontend/app/(app)/ideas/page.tsx`](../frontend/app/(app)/ideas/page.tsx) — list view (server component).
|
||||||
|
5. [`frontend/app/(app)/ideas/new/page.tsx`](../frontend/app/(app)/ideas/new/page.tsx) — create form (client component).
|
||||||
|
6. [`frontend/app/(app)/ideas/components/idea-list.tsx`](../frontend/app/(app)/ideas/components/idea-list.tsx).
|
||||||
|
7. [`frontend/app/(app)/ideas/components/idea-form.tsx`](../frontend/app/(app)/ideas/components/idea-form.tsx).
|
||||||
|
8. [`frontend/lib/api.ts`](../frontend/lib/api.ts) — thin fetch wrappers.
|
||||||
|
9. [`frontend/public/manifest.json`](../frontend/public/manifest.json).
|
||||||
|
10. [`frontend/public/sw.js`](../frontend/public/sw.js) — stub service worker.
|
||||||
|
11. [`frontend/lib/sw-register.ts`](../frontend/lib/sw-register.ts) — SW registration on first paint.
|
||||||
|
12. App icon set: `icon-192.png`, `icon-512.png`, `apple-touch-icon.png`, `favicon.ico`. Hand-drawn or `imagemagick`-generated single-colour campfire glyph. See [Open Question Q07](Open-Questions.md#q07-pwa-assets-pipeline).
|
||||||
|
|
||||||
|
**Acceptance gate:**
|
||||||
|
- Local dev: signed-in user can hit `/ideas`, see an empty list, click "+", post an idea, see it appear.
|
||||||
|
- `/manifest.json` returns 200 with valid content.
|
||||||
|
- DevTools → Application → Manifest shows green checkmarks.
|
||||||
|
- iOS Safari "Add to Home Screen" produces an icon labelled "Sparkboard".
|
||||||
|
- Android Chrome shows the install prompt.
|
||||||
|
- Satisfies the UI half of **A4**, all of **A5**, and the post-login navigation half of **A1**.
|
||||||
|
|
||||||
|
**Code sketch — [`frontend/app/(app)/ideas/page.tsx`](../frontend/app/(app)/ideas/page.tsx) (server component):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { listIdeas } from "@/lib/api";
|
||||||
|
import { IdeaList } from "./components/idea-list";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function IdeasPage() {
|
||||||
|
const ideas = await listIdeas();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||||
|
<header className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Sparkboard</h1>
|
||||||
|
<Link
|
||||||
|
href="/ideas/new"
|
||||||
|
className="rounded-md bg-orange-600 px-4 py-2 text-white text-sm"
|
||||||
|
>
|
||||||
|
+ New
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
<IdeaList ideas={ideas} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code sketch — [`frontend/lib/api.ts`](../frontend/lib/api.ts):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
export type Idea = {
|
||||||
|
id: string;
|
||||||
|
authorId: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
status: "RAW" | "EXPLORING" | "BUILDING" | "SHIPPED" | "DEAD";
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ORIGIN = process.env.NEXTAUTH_URL ?? "http://localhost:3000";
|
||||||
|
|
||||||
|
export async function listIdeas(): Promise<Idea[]> {
|
||||||
|
const res = await fetch(`${ORIGIN}/api/backend/api/ideas`, {
|
||||||
|
headers: { cookie: (await cookies()).toString() },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`listIdeas: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createIdea(input: { title: string; description?: string }) {
|
||||||
|
const res = await fetch(`${ORIGIN}/api/backend/api/ideas`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", cookie: (await cookies()).toString() },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`createIdea: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code sketch — [`frontend/app/(app)/ideas/components/idea-form.tsx`](../frontend/app/(app)/ideas/components/idea-form.tsx) (client component):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
import { useTransition, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export function IdeaForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
|
const submit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
start(async () => {
|
||||||
|
const res = await fetch("/api/backend/api/ideas", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ title, description: description || undefined }),
|
||||||
|
});
|
||||||
|
if (res.ok) router.push("/ideas");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<input
|
||||||
|
autoFocus required maxLength={200}
|
||||||
|
value={title} onChange={e => setTitle(e.target.value)}
|
||||||
|
placeholder="Catch the spark…"
|
||||||
|
className="w-full text-lg border-b py-2 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={description} onChange={e => setDescription(e.target.value)}
|
||||||
|
placeholder="More detail (optional)"
|
||||||
|
className="w-full min-h-[120px] border rounded p-3"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit" disabled={pending || !title.trim()}
|
||||||
|
className="rounded bg-orange-600 px-4 py-2 text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? "Posting…" : "Post"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code sketch — [`frontend/public/manifest.json`](../frontend/public/manifest.json):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Sparkboard",
|
||||||
|
"short_name": "Sparkboard",
|
||||||
|
"description": "Catch the spark before it fades.",
|
||||||
|
"start_url": "/ideas",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0a0a0a",
|
||||||
|
"theme_color": "#ea580c",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||||
|
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code sketch — [`frontend/public/sw.js`](../frontend/public/sw.js) (stub, intentionally minimal):**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Sprint 1 service worker stub.
|
||||||
|
// Purpose: register, claim clients, fail closed. NO caching.
|
||||||
|
// Real offline caching arrives in Sprint 4 (Ember).
|
||||||
|
self.addEventListener("install", e => self.skipWaiting());
|
||||||
|
self.addEventListener("activate", e => e.waitUntil(self.clients.claim()));
|
||||||
|
self.addEventListener("fetch", () => { /* let the network handle it */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code sketch — [`frontend/lib/sw-register.ts`](../frontend/lib/sw-register.ts):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
"use client";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function ServiceWorkerRegister() {
|
||||||
|
useEffect(() => {
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wired into `app/layout.tsx` once.
|
||||||
|
|
||||||
|
**On protecting the `(app)` route group:**
|
||||||
|
NextAuth v5 + `@platesoft/auth`'s middleware re-export handles auth-aware redirects. Sparkboard's `middleware.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export { default } from "@platesoft/auth/middleware";
|
||||||
|
export const config = { matcher: ["/((?!login|api/auth|_next|icons|sw.js|manifest.json).*)"] };
|
||||||
|
```
|
||||||
|
|
||||||
|
Anything matched and unauthenticated → bounce to `/login`. That's the entire client-side auth check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### W5 — Seed data
|
||||||
|
|
||||||
|
**Goal:** local dev and CI environments start with a tiny but useful dataset so the four humans (and the test runner) can see something on day one.
|
||||||
|
|
||||||
|
**Pre-requisite:** W2 + W3 + W4 done. The migration system works.
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
|
||||||
|
1. [`backend/src/main/resources/db/migration/V2__seed_family_spark_org.sql`](../backend/src/main/resources/db/migration/V2__seed_family_spark_org.sql) — already done in W2.
|
||||||
|
2. **No idea seed data in production.** The `ideas` table starts empty. Patrick will post the first idea himself ("Sparkboard exists.") and that becomes the canonical first row.
|
||||||
|
3. _Dev-only_ [`backend/src/main/resources/db/migration/dev/`](../backend/src/main/resources/db/migration/dev/) Flyway location, activated with `spring.profiles.active=dev` — seeds 3 example ideas with a fixed `author_id` of `00000000-0000-0000-0000-000000000099` ("Dev User") so the list page is non-empty when the developer boots locally.
|
||||||
|
4. Local dev convenience: a `dev-login` button on `/login` that POSTs to a `dev-only` `/api/auth/dev-login` endpoint shipped by plate-auth's `dev` profile (already provided by plate-auth — see [plate-auth Integration Guide §3](https://git.plate-software.de/pplate/plate-auth/wiki/Integration-Guide)). Sparkboard does **not** implement this endpoint; just configures the flag.
|
||||||
|
|
||||||
|
**Acceptance gate:**
|
||||||
|
- Local backend boot with `spring.profiles.active=dev` populates 3 example ideas.
|
||||||
|
- Local backend boot **without** dev profile leaves `ideas` empty.
|
||||||
|
- Local frontend boot with `dev` env shows a "Dev login" button alongside "Sign in with Google".
|
||||||
|
- Prod boot has neither dev migration nor dev login.
|
||||||
|
|
||||||
|
**Code sketch — dev seed migration `db/migration/dev/R__dev_seed_ideas.sql` (Flyway _repeatable_, prefix `R__`):**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Runs every dev boot if changed. Idempotent via WHERE NOT EXISTS.
|
||||||
|
INSERT INTO ideas (id, org_id, author_id, title, description, status, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
'11111111-1111-1111-1111-111111111111',
|
||||||
|
'00000000-0000-0000-0000-000000000001',
|
||||||
|
'00000000-0000-0000-0000-000000000099',
|
||||||
|
'Sparkboard exists.',
|
||||||
|
'The first canonical idea. Burn it down if you want.',
|
||||||
|
'EXPLORING',
|
||||||
|
now() - interval '3 days',
|
||||||
|
now() - interval '3 days'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ideas WHERE id = '11111111-1111-1111-1111-111111111111');
|
||||||
|
|
||||||
|
-- Two more sample ideas elided for brevity.
|
||||||
|
```
|
||||||
|
|
||||||
|
Activated via `spring.flyway.locations=classpath:db/migration,classpath:db/migration/dev` in `application-dev.yml`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### W6 — Deploy + CI/CD
|
||||||
|
|
||||||
|
**Goal:** a push to `main` on `git.plate-software.de/pplate/sparkboard` results in a healthy `https://sparkboard.plate-software.de` within 10 minutes. Wall-clock end-to-end.
|
||||||
|
|
||||||
|
**Pre-requisite:** W4 complete (a build that runs locally). All Sprint-0 prereqs done: DNS, frps port 30011, IONOS vhost, TrueNAS dataset, Google OAuth client.
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
|
||||||
|
1. [`backend/Dockerfile`](../backend/Dockerfile) — multi-stage, distroless Java 25 runtime.
|
||||||
|
2. [`frontend/Dockerfile`](../frontend/Dockerfile) — multi-stage, Next.js standalone build on Node 22.
|
||||||
|
3. [`docker-compose.yml`](../docker-compose.yml) — local dev (backend + frontend + postgres).
|
||||||
|
4. [`docker-compose.prod.yml`](../docker-compose.prod.yml) — TrueNAS production (adds named volumes, host network for frpc, restart policies).
|
||||||
|
5. [`deploy/caddy/Caddyfile`](../deploy/caddy/Caddyfile) — internal reverse proxy on TrueNAS routing `/api/*` → backend, everything else → frontend.
|
||||||
|
6. [`deploy/deploy.sh`](../deploy/deploy.sh) — SSH-side script: pull images, run `docker compose -f docker-compose.prod.yml up -d`, prune.
|
||||||
|
7. [`deploy/smoke-test.sh`](../deploy/smoke-test.sh) — post-deploy: `curl https://sparkboard.plate-software.de/api/health`, `/login`, `/manifest.json` — all must return 2xx.
|
||||||
|
8. [`.gitea/workflows/deploy.yml`](../.gitea/workflows/deploy.yml) — on push to `main`: build → push to Gitea registry → SSH-deploy.
|
||||||
|
9. IONOS Apache vhost block (out-of-repo, lives on the IONOS server): `sparkboard.plate-software.de` → `http://<truenas-ip>:30011`.
|
||||||
|
10. `frpc.toml` entry on TrueNAS, new section:
|
||||||
|
```toml
|
||||||
|
[sparkboard]
|
||||||
|
type = "tcp"
|
||||||
|
local_ip = "127.0.0.1"
|
||||||
|
local_port = 8080 # caddy on truenas
|
||||||
|
remote_port = 30011
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance gate:**
|
||||||
|
- A merge to `main` triggers `.gitea/workflows/deploy.yml`.
|
||||||
|
- The workflow builds both images, pushes to `docker.git.plate-software.de/pplate/sparkboard-backend:<sha>` and `sparkboard-frontend:<sha>`.
|
||||||
|
- The SSH step runs `deploy/deploy.sh` on TrueNAS; the script picks up the new images and recreates the containers.
|
||||||
|
- `deploy/smoke-test.sh` runs against `https://sparkboard.plate-software.de` and passes.
|
||||||
|
- A real Google sign-in from a phone on cellular reaches `/ideas` and shows an empty list (or the dev seed if dev). End-to-end.
|
||||||
|
- Satisfies **A6** and is the final closing gate for **A1**.
|
||||||
|
|
||||||
|
**Code sketch — [`backend/Dockerfile`](../backend/Dockerfile):**
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM maven:3.9-eclipse-temurin-25 AS build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY pom.xml .
|
||||||
|
RUN mvn -B -e -ntp dependency:go-offline
|
||||||
|
COPY src src
|
||||||
|
RUN mvn -B -e -ntp -DskipTests package
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/java25-debian12:nonroot
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /build/target/sparkboard-backend-*.jar app.jar
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code sketch — [`frontend/Dockerfile`](../frontend/Dockerfile):**
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN corepack enable && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN corepack enable && pnpm build
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/nodejs22-debian12:nonroot
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/.next/standalone /app/
|
||||||
|
COPY --from=build /app/.next/static /app/.next/static
|
||||||
|
COPY --from=build /app/public /app/public
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000 HOSTNAME=0.0.0.0
|
||||||
|
CMD ["server.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code sketch — [`docker-compose.prod.yml`](../docker-compose.prod.yml) (excerpt):**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: sparkboard
|
||||||
|
POSTGRES_USER: ${DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- /mnt/tank/sparkboard/pgdata:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: docker.git.plate-software.de/pplate/sparkboard-backend:${SHA}
|
||||||
|
depends_on: [postgres]
|
||||||
|
environment:
|
||||||
|
SPRING_PROFILES_ACTIVE: prod
|
||||||
|
DB_HOST: postgres
|
||||||
|
DB_USER: ${DB_USER}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
PLATE_AUTH_JWT_SECRET: ${PLATE_AUTH_JWT_SECRET}
|
||||||
|
PLATE_AUTH_EXCHANGE_SECRET: ${PLATE_AUTH_EXCHANGE_SECRET}
|
||||||
|
PLATE_AUTH_ALLOWLIST_EMAILS: ${PLATE_AUTH_ALLOWLIST_EMAILS}
|
||||||
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||||
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: docker.git.plate-software.de/pplate/sparkboard-frontend:${SHA}
|
||||||
|
depends_on: [backend]
|
||||||
|
environment:
|
||||||
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||||
|
NEXTAUTH_URL: https://sparkboard.plate-software.de
|
||||||
|
PLATE_AUTH_BACKEND_URL: http://backend:8080
|
||||||
|
PLATE_AUTH_EXCHANGE_SECRET: ${PLATE_AUTH_EXCHANGE_SECRET}
|
||||||
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||||
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
ports: ["127.0.0.1:8080:8080"] # frpc forwards 30011 → 8080
|
||||||
|
depends_on: [backend, frontend]
|
||||||
|
volumes:
|
||||||
|
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code sketch — [`deploy/caddy/Caddyfile`](../deploy/caddy/Caddyfile):**
|
||||||
|
|
||||||
|
```
|
||||||
|
:8080 {
|
||||||
|
encode gzip
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy backend:8080
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
reverse_proxy frontend:3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code sketch — [`.gitea/workflows/deploy.yml`](../.gitea/workflows/deploy.yml):**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Deploy
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Login Gitea registry
|
||||||
|
run: echo "${{ secrets.GITEA_DEPLOY_TOKEN }}" | docker login docker.git.plate-software.de -u pplate --password-stdin
|
||||||
|
- name: Build backend
|
||||||
|
run: docker build -t docker.git.plate-software.de/pplate/sparkboard-backend:${{ github.sha }} ./backend
|
||||||
|
- name: Build frontend
|
||||||
|
run: docker build -t docker.git.plate-software.de/pplate/sparkboard-frontend:${{ github.sha }} ./frontend
|
||||||
|
- name: Push images
|
||||||
|
run: |
|
||||||
|
docker push docker.git.plate-software.de/pplate/sparkboard-backend:${{ github.sha }}
|
||||||
|
docker push docker.git.plate-software.de/pplate/sparkboard-frontend:${{ github.sha }}
|
||||||
|
- name: SSH deploy
|
||||||
|
uses: appleboy/ssh-action@v1.0.0
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.TRUENAS_HOST }}
|
||||||
|
username: ${{ secrets.TRUENAS_USER }}
|
||||||
|
key: ${{ secrets.TRUENAS_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /mnt/tank/sparkboard
|
||||||
|
export SHA=${{ github.sha }}
|
||||||
|
./deploy.sh
|
||||||
|
- name: Smoke test
|
||||||
|
run: ./deploy/smoke-test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code sketch — [`deploy/deploy.sh`](../deploy/deploy.sh):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
echo "Deploying SHA=${SHA}"
|
||||||
|
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||||
|
docker image prune -af --filter "until=168h"
|
||||||
|
|
||||||
|
echo "Deploy complete."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code sketch — [`deploy/smoke-test.sh`](../deploy/smoke-test.sh):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
URL="https://sparkboard.plate-software.de"
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local path="$1" expected="$2"
|
||||||
|
local got
|
||||||
|
got=$(curl -sS -o /dev/null -w "%{http_code}" "$URL$path")
|
||||||
|
[[ "$got" == "$expected" ]] || { echo "FAIL $path: got $got, expected $expected"; exit 1; }
|
||||||
|
echo "OK $path → $got"
|
||||||
|
}
|
||||||
|
|
||||||
|
check /api/health 200
|
||||||
|
check /login 200
|
||||||
|
check /manifest.json 200
|
||||||
|
check /sw.js 200
|
||||||
|
|
||||||
|
echo "All smoke checks passed."
|
||||||
|
```
|
||||||
|
|
||||||
|
`/api/health` is provided by `spring-boot-starter-actuator` — Sparkboard exposes `management.endpoints.web.exposure.include=health` in `application-prod.yml`. No code written.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Continued in [Sprint-1-Plan-Part-4](Sprint-1-Plan-Part-4.md): implementation order, acceptance→workstream mapping, open questions._
|
||||||
Reference in New Issue
Block a user