From 517066dc65cef1b24ceedf3df33888fd7f4d78c2 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 15 Aug 2026 01:20:42 +0200 Subject: [PATCH] feat: add Docker image, static server and GitLab CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .dockerignore | 12 +++++ .gitlab-ci.yml | 127 +++++++++++++++++++++++++++++++++++++++++++++ Dockerfile | 42 +++++++++++++++ README.md | 20 ++++++- package.json | 5 +- serve.ts | 96 ++++++++++++++++++++++++++++++++++ test/serve.test.ts | 81 +++++++++++++++++++++++++++++ 7 files changed, 379 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitlab-ci.yml create mode 100644 Dockerfile create mode 100644 serve.ts create mode 100644 test/serve.test.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5717c3d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.gitlab-ci.yml +node_modules +public +data +*.sqlite +.idea +docs +.claude +README.md +CLAUDE.md diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..5e6c881 --- /dev/null +++ b/.gitlab-ci.yml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4e76691 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index b79de00..183acd9 100644 --- a/README.md +++ b/README.md @@ -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 . +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. diff --git a/package.json b/package.json index 55cec71..f7be3be 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/serve.ts b/serve.ts new file mode 100644 index 0000000..d778280 --- /dev/null +++ b/serve.ts @@ -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 = { + ".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}`); diff --git a/test/serve.test.ts b/test/serve.test.ts new file mode 100644 index 0000000..c84e3e7 --- /dev/null +++ b/test/serve.test.ts @@ -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(""); + }); + + 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"); + }); +});