chore: Dockerfile, Gitea CI image build, docker-compose example
The server now stops on SIGTERM/SIGINT so docker stop is immediate. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2282897b4b
commit
ec51b7b5fc
@@ -0,0 +1,19 @@
|
||||
.git
|
||||
.gitea
|
||||
.claude
|
||||
.codex
|
||||
.gemini
|
||||
.impeccable
|
||||
.superpowers
|
||||
.worktrees
|
||||
node_modules
|
||||
dist
|
||||
samples
|
||||
docs
|
||||
test
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
.dev.vars
|
||||
docker-compose*.yml
|
||||
Dockerfile
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Continuous Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.lucaswinther.info
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
- name: Run test suite
|
||||
run: bun test
|
||||
- name: Build page and userscript
|
||||
run: bun run build
|
||||
|
||||
docker-build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-and-test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Gitea's job token cannot publish packages; use a package-write PAT.
|
||||
- name: Log in to Gitea Container Registry
|
||||
if: gitea.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ gitea.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ gitea.sha }}
|
||||
BUILD_REPOSITORY=https://gitea.lucaswinther.info/${{ gitea.repository }}
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
# Read-only smoke test: no storage, no outbound calls; the page, config and userscript must be served.
|
||||
- name: Pull and smoke-test published image
|
||||
if: gitea.event_name != 'pull_request'
|
||||
env:
|
||||
PUBLISHED_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
image="$(printf '%s\n' "$PUBLISHED_TAGS" | head -n 1)"
|
||||
docker pull "$image"
|
||||
docker run -d --rm --name mangabaka-seeder-ci "$image"
|
||||
trap 'docker stop mangabaka-seeder-ci' EXIT
|
||||
docker exec mangabaka-seeder-ci bun -e '
|
||||
const base = "http://127.0.0.1:8787";
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
try {
|
||||
const response = await fetch(base + "/");
|
||||
if (response.ok && (await response.text()).includes("MangaBaka Seeder")) {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
await Bun.sleep(1000);
|
||||
}
|
||||
if (!ready) throw new Error("App did not serve its HTML");
|
||||
const config = await (await fetch(base + "/api/config")).json();
|
||||
if (config.storage !== false) throw new Error("expected no storage without MBSEED_STORAGE_DIR");
|
||||
const script = await fetch(base + "/mbseed.user.js");
|
||||
if (!script.ok || !(await script.text()).startsWith("// ==UserScript==")) throw new Error("/mbseed.user.js missing");
|
||||
const refused = await fetch(base + "/api/fetch?url=https://example.org/");
|
||||
if (refused.status !== 403) throw new Error(`/api/fetch without x-mbseed returned ${refused.status}`);
|
||||
'
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# mangabaka-seeder: the page, the userscript and the Bun server in one image.
|
||||
# Configure with MBSEED_* environment variables (docs/ARCHITECTURE.md, "Running the server").
|
||||
|
||||
FROM oven/bun:1.3.14-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
COPY scripts ./scripts
|
||||
RUN bun run build:web
|
||||
|
||||
FROM oven/bun:1.3.14-slim
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG BUILD_REPOSITORY=https://gitea.lucaswinther.info/lucasw89/mangabaka-seeder
|
||||
LABEL org.opencontainers.image.title="mangabaka-seeder" \
|
||||
org.opencontainers.image.description="Seed WEBTOON covers into MangaBaka cover edit requests" \
|
||||
org.opencontainers.image.source="${BUILD_REPOSITORY}" \
|
||||
org.opencontainers.image.revision="${BUILD_VERSION}"
|
||||
WORKDIR /app
|
||||
# No runtime dependencies: the server, the page and the userscript build need only Bun.
|
||||
COPY --from=build /app/package.json ./
|
||||
COPY --from=build /app/src ./src
|
||||
COPY --from=build /app/scripts ./scripts
|
||||
COPY --from=build /app/dist ./dist
|
||||
# Storage is off unless MBSEED_STORAGE_DIR is set (with MBSEED_PUBLIC_URL); /data/covers is the conventional place.
|
||||
RUN mkdir -p /data/covers && chown -R bun:bun /data /app/dist
|
||||
ENV MBSEED_HOST=0.0.0.0 \
|
||||
MBSEED_PORT=8787 \
|
||||
MBSEED_ASSETS_DIR=/app/dist/web
|
||||
USER bun
|
||||
EXPOSE 8787
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD ["bun", "-e", "fetch('http://127.0.0.1:' + (process.env.MBSEED_PORT ?? 8787) + '/api/config').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
|
||||
# The userscript is built at start so its @connect list includes MBSEED_PUBLIC_URL's host.
|
||||
CMD ["sh", "-c", "bun run scripts/build-userscript.ts && exec bun run src/server/bun.ts"]
|
||||
@@ -101,6 +101,19 @@ its own storage and the page offers litterbox, catbox and no rehosting. All
|
||||
settings, and how to put it behind a reverse proxy, are in
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md#running-the-server).
|
||||
|
||||
### With Docker
|
||||
|
||||
Every push to `main` builds an image and publishes it to
|
||||
`gitea.lucaswinther.info/lucasw89/mangabaka-seeder` (`latest`, the branch name,
|
||||
`sha-<commit>`, and the version for `v*` tags). Copy
|
||||
[`docker-compose.example.yml`](docker-compose.example.yml) to `docker-compose.yml`,
|
||||
set `MBSEED_PUBLIC_URL`, and run `docker compose up -d`. To build it yourself:
|
||||
`docker build -t mangabaka-seeder .`
|
||||
|
||||
The CI workflow (`.gitea/workflows/ci.yml`) publishes with a repository or user
|
||||
secret `REGISTRY_TOKEN`: a Gitea access token with package write permission
|
||||
(Gitea's job token cannot publish packages).
|
||||
|
||||
### The userscript
|
||||
|
||||
Install `/mbseed.user.js` from the running server (or `dist/web/mbseed.user.js`)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Example: run mangabaka-seeder behind your reverse proxy.
|
||||
# Copy to docker-compose.yml, set MBSEED_PUBLIC_URL to the address your proxy serves, then:
|
||||
# docker compose up -d
|
||||
# All settings: docs/ARCHITECTURE.md, "Running the server".
|
||||
services:
|
||||
mangabaka-seeder:
|
||||
image: gitea.lucaswinther.info/lucasw89/mangabaka-seeder:latest
|
||||
restart: unless-stopped
|
||||
# Only the reverse proxy should reach it; it terminates TLS and appends X-Forwarded-For.
|
||||
ports:
|
||||
- "127.0.0.1:8787:8787"
|
||||
environment:
|
||||
# Public origin (no path). Stored covers are served from it, and the userscript may connect to it.
|
||||
MBSEED_PUBLIC_URL: https://mbseed.example.org
|
||||
# Rehost covers on this server for 30 days. Remove both lines to run without storage
|
||||
# (the page then offers litterbox, catbox and no rehosting).
|
||||
MBSEED_STORAGE_DIR: /data/covers
|
||||
MBSEED_STORAGE_MAX_BYTES: "1073741824" # 1 GiB; uploads past it get 507, nothing is evicted early
|
||||
# Rate-limit on the address your proxy appends to X-Forwarded-For. Leave unset without a proxy.
|
||||
MBSEED_TRUST_PROXY: "1"
|
||||
volumes:
|
||||
# A named volume works as is. For a bind mount, make the directory writable by uid 1000 (the image's "bun" user).
|
||||
- covers:/data/covers
|
||||
|
||||
volumes:
|
||||
covers:
|
||||
@@ -42,6 +42,13 @@ user edits them, then `rehost` (explicit) → `manualText` for the cover form, o
|
||||
Put it behind a reverse proxy that terminates TLS and **appends** the client address to
|
||||
`X-Forwarded-For` (nginx `$proxy_add_x_forwarded_for`, Caddy's default), then set `MBSEED_TRUST_PROXY=1`.
|
||||
|
||||
**Docker.** `Dockerfile` builds the page in one stage and runs the server as user `bun` (uid 1000) on
|
||||
`0.0.0.0:8787`; `MBSEED_ASSETS_DIR=/app/dist/web`. Storage stays off unless `MBSEED_STORAGE_DIR` is set
|
||||
(`/data/covers` exists for it). The container builds the userscript at start, so its `@connect` list
|
||||
includes `MBSEED_PUBLIC_URL`'s host. `bun.ts` stops on SIGTERM/SIGINT (Bun as PID 1 ignores them otherwise).
|
||||
`.gitea/workflows/ci.yml` typechecks, tests and builds, then builds and pushes the image (not for pull
|
||||
requests) and smoke-tests it. Example deployment: `docker-compose.example.yml`.
|
||||
|
||||
## External dependencies
|
||||
| Service | Reached via | If unavailable |
|
||||
|---|---|---|
|
||||
|
||||
@@ -16,6 +16,23 @@ the repo every session — measurably slower and more tokens).
|
||||
**Consequences:** Rule changes happen in one file; pointer files must stay thin. Plans are working
|
||||
artefacts: kept after merge they go stale and contradict the code, so the spec is the record.
|
||||
|
||||
## 2026-10-15 — Docker image built by Gitea CI
|
||||
**Status:** accepted
|
||||
**Context:** The user wants a Dockerfile that Gitea builds automatically, plus a docker-compose example,
|
||||
mirroring the working workflow in their `budget2.0` repository.
|
||||
**Decision:** Two-stage image on `oven/bun:1.3.14-slim` (no runtime dependencies, so only `src`, `scripts`
|
||||
and the built page are copied). It runs as `bun`, listens on `0.0.0.0:8787`, and has a healthcheck on
|
||||
`/api/config`. Storage is off by default; the compose example turns it on in a named volume at `/data/covers`.
|
||||
The userscript is built when the container starts, not in the image, because its `@connect` list depends on
|
||||
`MBSEED_PUBLIC_URL`, which is only known at run time. `bun.ts` handles SIGTERM/SIGINT: without it Bun as
|
||||
PID 1 ignored `docker stop` and was killed after 10 s. The workflow copies budget2.0: tests first, then
|
||||
Buildx, push to the Gitea registry with a `REGISTRY_TOKEN` secret (a package-write token; Gitea's job
|
||||
token cannot publish packages), and a read-only smoke test of the pushed image.
|
||||
**Alternatives:** Building the userscript in the image (wrong `@connect` for every deployment);
|
||||
a distroless or compiled-binary image (smaller, but harder to debug, and Bun's slim image is enough).
|
||||
**Consequences:** The `REGISTRY_TOKEN` secret must exist in Gitea before the image job can push. Starting
|
||||
the container takes about a second longer for the userscript build.
|
||||
|
||||
## 2026-10-15 — Harden the server proxy and uploads (final review)
|
||||
**Status:** accepted (amends "Implementation details: server API" below)
|
||||
**Context:** The whole-branch review found that `/api/fetch` passed the upstream content type through. Anyone can
|
||||
|
||||
@@ -8,6 +8,8 @@ Design: [specs/001-webtoons-covers.md](specs/001-webtoons-covers.md).
|
||||
- [ ] **User smoke test** in a real browser: the page (local server) and the userscript in Tampermonkey or
|
||||
Violentmonkey on a MangaBaka series page — Tower of God (9999 / title_no 95). The agent ran both in
|
||||
headless Chrome (the userscript with a GM shim); a real GM install is still untested.
|
||||
- [ ] **Gitea secret `REGISTRY_TOKEN`** (needs the user): an access token with package write permission, as a
|
||||
repository or user secret, so CI can push the image. Then check the first run of `.gitea/workflows/ci.yml`.
|
||||
- [ ] **Deploy** (needs the user): which server and domain (`MBSEED_PUBLIC_URL`), where the covers folder lives,
|
||||
whether the 1 GiB cap suits the disk, reverse proxy with TLS that appends `X-Forwarded-For`.
|
||||
|
||||
|
||||
@@ -37,3 +37,10 @@ console.log(
|
||||
storage ? `${config.storageDir} (${storage.usedBytes()} of ${config.storageMaxBytes} bytes)` : "off"
|
||||
}`,
|
||||
);
|
||||
|
||||
// In a container Bun runs as PID 1, which ignores SIGTERM without a handler: stop cleanly on docker stop.
|
||||
for (const signal of ["SIGTERM", "SIGINT"] as const) {
|
||||
process.on(signal, () => {
|
||||
void server.stop().finally(() => process.exit(0));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user