feat: add Docker image, static server and GitLab CI

The server is a placeholder for the one in docs/ARCHITECTURE.md — it serves
public/ and a health endpoint, nothing more. Reads are confined to public/ by
resolving the path and checking it stays inside the root; string-matching
".." is not enough, since encodings and URL normalisation both change what
the string looks like and only the resolved path says which file would open.

The image runs typecheck and tests during build, ships no source or
toolchain, and runs unprivileged.

CI's feed job fails if the event count collapses. A source that quietly stops
yielding events is what a parser-only pipeline is most prone to, and nothing
else would surface it. Everything is offline, so a red pipeline always means
the code changed rather than a wiki being down.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 01:20:42 +02:00
co-authored by Claude Opus 5
parent 71bff72ee9
commit 517066dc65
7 changed files with 379 additions and 4 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
.github
.gitlab-ci.yml
node_modules
public
data
*.sqlite
.idea
docs
.claude
README.md
CLAUDE.md
+127
View File
@@ -0,0 +1,127 @@
# Event Clock CI/CD
#
# Quality gates run on every push; the image is built only when they pass, and
# published only from the default branch. Nothing here reaches the network for
# source data — the build parses checked-in fixtures, so CI is hermetic and a
# wiki being down never turns the pipeline red.
stages:
- check
- build
- publish
default:
image: oven/bun:1.3-alpine
interruptible: true
cache:
key:
files:
- bun.lock
paths:
- node_modules/
variables:
# Full history is not needed; a shallow clone is faster.
GIT_DEPTH: "20"
IMAGE: $CI_REGISTRY_IMAGE
.bun-deps: &bun-deps
before_script:
- bun install --frozen-lockfile
# ---- check -----------------------------------------------------------------
typecheck:
stage: check
<<: *bun-deps
script:
- bun run typecheck
test:
stage: check
<<: *bun-deps
script:
- bun test
# Tests are offline by design: no fixture is re-fetched, so a red build always
# means the code changed, never that a source was unreachable.
feed:
stage: check
<<: *bun-deps
script:
- bun run build:feed
# A source that silently stops yielding events is the failure mode this
# pipeline exists to catch, so assert the feed is not empty or truncated.
- |
bun -e '
const feed = await Bun.file("public/data/events.v1.json").json();
const games = new Set(feed.events.map((e) => e.game));
console.log(`${feed.events.length} events across ${games.size} games`);
if (feed.events.length < 20) {
throw new Error(`feed collapsed to ${feed.events.length} events`);
}
const undated = feed.events.filter((e) => !e.startsAt);
if (undated.length > 0) throw new Error("events without a start date");
'
artifacts:
paths:
- public/data/events.v1.json
expire_in: 1 week
# ---- build -----------------------------------------------------------------
site:
stage: build
<<: *bun-deps
needs: [typecheck, test]
script:
- bun run build
artifacts:
paths:
- public/
expire_in: 1 week
container:
stage: build
needs: [typecheck, test]
image: docker:27
services:
- docker:27-dind
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
script:
- docker build --pull -t "$IMAGE:$CI_COMMIT_SHORT_SHA" .
- docker push "$IMAGE:$CI_COMMIT_SHORT_SHA"
rules:
# Building an image for every branch fills the registry; do it where the
# artefact could actually be deployed.
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
# ---- publish ---------------------------------------------------------------
tag-latest:
stage: publish
needs: [container]
image: docker:27
services:
- docker:27-dind
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
script:
- docker pull "$IMAGE:$CI_COMMIT_SHORT_SHA"
- docker tag "$IMAGE:$CI_COMMIT_SHORT_SHA" "$IMAGE:latest"
- docker push "$IMAGE:latest"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
pages:
stage: publish
needs: [site]
script:
- mv public .public && mv .public public
artifacts:
paths:
- public
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+42
View File
@@ -0,0 +1,42 @@
# Build the static site, then serve it from a distroless-ish runtime.
#
# Two stages so the image ships the built assets and nothing else: no source,
# no fixtures, no toolchain. The build is fully offline — it parses checked-in
# fixtures rather than fetching anything — so the image is reproducible and
# needs no network at build time.
FROM oven/bun:1.3-alpine AS build
WORKDIR /app
# Dependencies first, so a source-only change reuses this layer.
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY tsconfig.json index.html ./
COPY src ./src
COPY scripts ./scripts
COPY fixtures ./fixtures
COPY test ./test
# Fail the image on a type error or a failing test rather than shipping it.
RUN bun run typecheck && bun test
RUN bun run build
FROM oven/bun:1.3-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Run unprivileged. The bun image ships a `bun` user; use it rather than root.
COPY --from=build --chown=bun:bun /app/public ./public
COPY --from=build --chown=bun:bun /app/serve.ts ./serve.ts
USER bun
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD bun -e "await fetch('http://127.0.0.1:'+(process.env.PORT??3000)+'/api/health').then(r=>{if(!r.ok)process.exit(1)})"
CMD ["bun", "run", "serve.ts"]
+18 -2
View File
@@ -28,12 +28,21 @@ against real parsed data, and it produces exactly the shape the server will serv
```bash
bun install
bun run build # parse fixtures → feed, then compile CSS and JS
bunx serve public # or any static file server
bun run dev # build, then serve on :3000
```
Then open <http://localhost:3000>.
Or with Docker:
```bash
docker build -t event-clock .
docker run --rm -p 3000:3000 event-clock
```
The image build runs typecheck and tests, and parses only checked-in fixtures — no network, so it is
reproducible and a wiki being down never breaks it.
## Commands
```bash
@@ -137,6 +146,13 @@ so iteration never re-fetches. Every event links back to its source.
| `docs/DATA-MODEL.md` | Event schema, SQLite tables, client storage |
| `docs/INGESTION.md` | Parser/adapter/merge layers, pipeline stages, review gate |
## CI
`.gitlab-ci.yml` runs typecheck, tests and a feed sanity check on every push, then builds and
publishes a container image from the default branch. The feed job fails if the event count collapses
— a source that quietly stops yielding events is the failure mode a parser-only pipeline is most
prone to, and nothing else surfaces it.
## Licence
Not yet chosen. Event data belongs to the sources it came from and is linked back on every event.
+3 -2
View File
@@ -10,8 +10,9 @@
"build:css": "bunx @tailwindcss/cli -i src/client/styles.css -o public/styles.css --minify",
"build:js": "bun build src/client/main.tsx --outfile public/main.js --minify",
"build": "bun run build:feed && bun run build:css && bun run build:js && bun run build:static",
"dev": "bun run build && bunx serve public -l 3000",
"build:static": "cp index.html public/index.html && cp src/client/sw.js public/sw.js && cp src/client/manifest.webmanifest public/manifest.webmanifest && cp src/client/icon.svg public/icon.svg"
"dev": "bun run build && bun run serve.ts",
"build:static": "cp index.html public/index.html && cp src/client/sw.js public/sw.js && cp src/client/manifest.webmanifest public/manifest.webmanifest && cp src/client/icon.svg public/icon.svg",
"serve": "bun run serve.ts"
},
"dependencies": {
"react": "^19.2.8",
+96
View File
@@ -0,0 +1,96 @@
/**
* Static file server for the built app.
*
* A placeholder for the Bun server in docs/ARCHITECTURE.md: it serves what is
* in public/ and nothing else. When the real server lands it will add /api/*
* and the ingest scheduler, and this file goes away.
*
* Deliberately minimal — no framework, no dependencies beyond Bun.
*/
import { resolve } from "node:path";
const PORT = Number(process.env.PORT ?? 3000);
const ROOT = "public";
const ROOT_DIR = resolve(ROOT);
/** Long-lived for fingerprint-free assets is wrong; keep it short and revalidate. */
const CACHE: Record<string, string> = {
".html": "public, max-age=0, must-revalidate",
".json": "public, max-age=300",
".js": "public, max-age=3600",
".css": "public, max-age=3600",
".svg": "public, max-age=86400",
".webmanifest": "public, max-age=3600",
};
function extname(path: string): string {
const i = path.lastIndexOf(".");
return i === -1 ? "" : path.slice(i);
}
const server = Bun.serve({
port: PORT,
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/api/health") {
const feed = Bun.file(resolve(ROOT_DIR, "data/events.v1.json"));
const ok = await feed.exists();
return Response.json(
{ status: ok ? "ok" : "no-feed", generatedAt: new Date().toISOString() },
{ status: ok ? 200 : 503 },
);
}
let decoded: string;
try {
decoded = decodeURIComponent(url.pathname);
} catch {
return new Response("Bad request", { status: 400 });
}
if (decoded.includes("\0")) {
return new Response("Bad request", { status: 400 });
}
const path = decoded === "/" ? "/index.html" : decoded;
// Confine every read to public/ by resolving the path and checking it is
// still inside the root. String-matching ".." is not enough: encodings and
// URL normalisation both change what the string looks like, and only the
// resolved path tells the truth about which file would be opened.
const resolved = resolve(ROOT_DIR, `.${path}`);
if (resolved !== ROOT_DIR && !resolved.startsWith(`${ROOT_DIR}/`)) {
return new Response("Bad request", { status: 400 });
}
const file = Bun.file(resolved);
if (await file.exists()) {
return new Response(file, {
headers: {
"cache-control": CACHE[extname(path)] ?? "public, max-age=600",
// The service worker must never be served stale, or a deploy can be
// pinned by an old worker indefinitely.
...(path === "/sw.js"
? { "cache-control": "no-cache", "service-worker-allowed": "/" }
: {}),
},
});
}
// Single-page app: unknown paths fall back to the shell so client routing
// and deep links work. Anything under /data or /api is a genuine 404.
if (!path.startsWith("/data") && !path.startsWith("/api")) {
const shell = Bun.file(resolve(ROOT_DIR, "index.html"));
if (await shell.exists()) {
return new Response(shell, {
headers: { "cache-control": "public, max-age=0, must-revalidate" },
});
}
}
return new Response("Not found", { status: 404 });
},
});
console.log(`Event Clock serving ${ROOT} on http://localhost:${server.port}`);
+81
View File
@@ -0,0 +1,81 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
/**
* The static server is small, but it reads from the filesystem based on a
* user-supplied path, so its confinement is worth pinning down.
*/
let proc: Bun.Subprocess;
let base: string;
beforeAll(async () => {
const port = 3200 + Math.floor(Math.random() * 300);
base = `http://127.0.0.1:${port}`;
proc = Bun.spawn(["bun", "run", "serve.ts"], {
env: { ...process.env, PORT: String(port) },
stdout: "ignore",
stderr: "ignore",
});
for (let i = 0; i < 50; i += 1) {
try {
await fetch(`${base}/api/health`);
return;
} catch {
await Bun.sleep(100);
}
}
throw new Error("server did not start");
});
afterAll(() => {
proc.kill();
});
describe("static server", () => {
test("serves the shell and the feed", async () => {
expect((await fetch(`${base}/`)).status).toBe(200);
const feed = await fetch(`${base}/data/events.v1.json`);
expect(feed.status).toBe(200);
expect((await feed.json()).schemaVersion).toBe(1);
});
test("reports health", async () => {
const res = await fetch(`${base}/api/health`);
expect(res.status).toBe(200);
expect((await res.json()).status).toBe("ok");
});
test("falls back to the shell for unknown routes", async () => {
const res = await fetch(`${base}/deep/link`);
expect(res.status).toBe(200);
expect(await res.text()).toContain("<!doctype html>");
});
test("404s missing data rather than serving the shell", async () => {
// A JSON fetch that silently receives HTML is far harder to debug than a
// clean 404.
expect((await fetch(`${base}/data/nope.json`)).status).toBe(404);
});
test("never serves a file outside public/", async () => {
for (const path of [
"/..%2fpackage.json",
"/..%2f..%2fetc/passwd",
"/%2e%2e/package.json",
"/%2e%2e%2f%2e%2e%2fpackage.json",
]) {
const res = await fetch(`${base}${path}`);
const body = await res.text();
// Either refused, or normalised to something inside public/ — but never
// the repository file itself.
expect(body).not.toContain('"name": "gacha-event-tracker"');
expect(body).not.toContain("root:x:0:0");
}
});
test("keeps the service worker uncached", async () => {
// A stale worker can pin an old deploy indefinitely.
const res = await fetch(`${base}/sw.js`);
expect(res.headers.get("cache-control")).toBe("no-cache");
});
});