# Build Contract — apps for the Sweent / Gitea CI/CD platform

**Give this whole document to an LLM (or developer) as the spec for scaffolding a new app.**
Any app that follows this contract will, once onboarded, **auto-build and auto-deploy on every
push to `main`** — no further wiring.

You are scaffolding an app to run on a self-hosted Gitea + Docker pipeline. Follow this contract
exactly. Where you see `__APP__`, substitute the app's chosen lowercase name (the same string is
used for the Gitea repo `Sweent/__APP__`, the image `sweent/__APP__`, and the deploy key).

---

## 1. What the platform gives you (do NOT build these)
- **Registry:** `gitea.cloud.sweent.net/sweent/__APP__` — images are pushed here automatically.
- **CI runner:** builds your `Dockerfile` with Docker BuildKit on every push to `main`, tags the
  image `:<run_number>` and `:latest`, pushes it, then redeploys the container on the host.
- **Injected CI secrets (already exist org-wide — just reference them, never redefine):**
  `REGISTRY_USER`, `REGISTRY_TOKEN`, `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`.
- **Runtime:** your container is published on a host **loopback port** and (optionally) reverse-
  proxied by Plesk to a public domain. The proxy target is stable across deploys.

## 2. What your repo MUST contain
```
__APP__/
├── Dockerfile                      # builds a self-contained image (REQUIRED)
├── .dockerignore                   # keep build context small (recommended)
├── .gitea/workflows/deploy.yml     # the pipeline workflow (REQUIRED — see below)
└── <your application source>
```

## 3. Hard requirements on the app + Dockerfile
1. **One HTTP service, one port.** The container must listen on a single HTTP port. Honor the
   `PORT` env var if possible, with a sane default. **Document this internal port** — the operator
   needs it at onboarding time.
2. **Self-contained image.** Everything needed to run is built into the image. No host mounts, no
   reliance on files outside the container.
3. **Stateless container.** The container is **destroyed and recreated on every deploy** — persist
   nothing important to the container filesystem. Use an external DB/object store for state.
4. **Config via environment variables**, not baked-in secrets. App-specific runtime secrets are
   set by the operator in the host compose file, **never committed to the repo or the image.**
5. **Listens on `0.0.0.0`** (not `127.0.0.1`) inside the container so the published port works.
6. **Recommended:** run as non-root (`USER`), expose a `GET /healthz` returning `200`, keep the
   image small (alpine/slim base), build deps in a layer separate from source for caching.

## 4. The workflow file (`.gitea/workflows/deploy.yml`)
Copy this verbatim and change ONLY the three `env:` values. Do not add Docker/registry/SSH setup —
the runner already provides `DOCKER_HOST`, host DNS, and credentials.
```yaml
name: build-push-deploy
on:
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      tag:
        description: "Existing image tag to (re)deploy without rebuilding"
        required: false
env:
  REGISTRY: gitea.cloud.sweent.net
  IMAGE: gitea.cloud.sweent.net/sweent/__APP__
  APP: __APP__
jobs:
  build-push-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - id: meta
        run: echo "tag=${{ github.run_number }}" >> "$GITHUB_OUTPUT"
      - name: Log in to Gitea registry
        run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
      - name: Build
        run: docker build --build-arg APP_VERSION="${{ steps.meta.outputs.tag }}" -t "$IMAGE:${{ steps.meta.outputs.tag }}" -t "$IMAGE:latest" .
      - name: Push
        run: |
          docker push "$IMAGE:${{ steps.meta.outputs.tag }}"
          docker push "$IMAGE:latest"
      - name: Deploy on host
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
            "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "redeploy $APP ${{ steps.meta.outputs.tag }}"
```

## 5. Reference Dockerfile (Node example — adapt the base image for your stack)
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev && npm cache clean --force
COPY . .
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
ENV PORT=8080
EXPOSE 8080
USER node
CMD ["node", "server.js"]
```
Other stacks: same idea — `EXPOSE` your port, default `PORT`, listen on `0.0.0.0`, run non-root.
Python→`python:3.12-slim`+uvicorn/gunicorn; Go→multi-stage `golang`→`gcr.io/distroless`; static→build then serve via `nginx:alpine` or `caddy`.

## 6. Onboarding handoff (operator does this once)
After the repo is scaffolded, the operator runs `add-app.sh __APP__ <host-port> <container-port>`,
which creates the host compose stack + registers `__APP__` in the deploy allowlist, then triggers
the first build. **Tell the operator the app's internal container port.** After that first deploy,
every push to `main` redeploys automatically.

## 7. Do / Don't
- ✅ DO: one service per repo, listen on `$PORT`/`0.0.0.0`, stateless, secrets via env, small image, `/healthz`.
- ❌ DON'T: commit secrets, write important data to the container FS, bind to `127.0.0.1` inside the container, add registry/SSH steps to the workflow, change the image name away from `sweent/__APP__`, or assume the previous container's state survives a deploy.
