diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7f1b71e..7fa7faf 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -49,3 +49,70 @@ jobs: env: NODE_ENV: production run: npm run build + + # Builds once per commit that lands on main and has passed the checks + # above, and pushes it to the registry tagged by commit SHA. release.yml + # (triggered by a version tag) never rebuilds — it only promotes this + # exact, already-tested image to the release tag + `latest` and deploys + # it. This guarantees byte-for-byte that what gets deployed is what CI + # verified, and makes tagging a commit that never passed CI on main fail + # loudly (no image to promote) instead of silently rebuilding from + # source and skipping the checks. + build-image: + needs: test + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: ubuntu-latest + # The anouma-runner runs as a Docker container itself (gitea/runner:3), + # with the host's docker.sock bind-mounted in but no `docker` CLI binary + # of its own — a job with no `container:` here runs directly inside that + # minimal runner image and gets "docker: command not found". + # + # A plain `docker:27-cli` image (Alpine, no Node.js) doesn't work either: + # actions/checkout@v4 is a JS action and needs a Node runtime in the job + # container, which that image doesn't have — it fails at Checkout with + # exit 127. Using a Node image as the base guarantees Node is already + # there (same reason the `test` job's node:22-alpine works), and the + # Docker CLI is added via apt. The runner auto-mounts the same host + # docker.sock into this container (sibling containers — see + # container.docker_host in the runner's config.yaml), so `docker + # build`/`push` operate on the host's real Docker daemon. release.yml's + # promote-and-deploy job uses the same setup, for the same reasons. + container: + image: node:22-bookworm-slim + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Docker CLI + run: | + apt-get update + apt-get install -y --no-install-recommends docker.io + rm -rf /var/lib/apt/lists/* + + - name: Determine image name + id: image + run: | + echo "registry=${REGISTRY:-git.maro.run}" >> "$GITHUB_OUTPUT" + echo "repo=${REGISTRY_REPO:-maro/anouma}" >> "$GITHUB_OUTPUT" + env: + REGISTRY: git.maro.run + REGISTRY_REPO: maro/anouma + + - name: Log in to registry + env: + REGISTRY_IMAGE: ${{ steps.image.outputs.registry }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_IMAGE" -u "$REGISTRY_USERNAME" --password-stdin + + - name: Build image + env: + IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }} + # Tagged by commit SHA only — `latest` is reserved for whatever was + # last actually released (see release.yml), not the newest main build. + run: docker build -t "$IMAGE:$GITHUB_SHA" . + + - name: Push image + env: + IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }} + run: docker push "$IMAGE:$GITHUB_SHA" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index e4782ae..b79e8ba 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -6,56 +6,23 @@ on: - 'v*' jobs: - test: + # Deliberately does NOT rebuild or re-run lint/typecheck/test/build: the + # image for this exact commit was already built and pushed by ci.yml's + # build-image job when it landed on main (which itself only runs after + # the `test` job passes). This job just promotes that already-verified + # image to the release tag + `latest` and deploys it — if the tagged + # commit was never pushed to main (or CI hasn't finished yet), the + # "Pull the tested image" step below fails loudly instead of silently + # rebuilding from source and skipping the checks. + promote-and-deploy: runs-on: ubuntu-latest - container: - image: node:22-alpine - # NODE_ENV is deliberately NOT set at job level: `npm ci` treats a - # production NODE_ENV as `--omit=dev`, which would skip eslint/ - # typescript/etc. and break Lint/Typecheck/Test. It's scoped to the - # Build step below instead. - env: - NEXT_TELEMETRY_DISABLED: "1" - DATABASE_URI: postgresql://ci:ci@localhost:5432/ci - PAYLOAD_SECRET: ci-only-not-a-real-secret - MEETING_SESSION_SECRET: ci-only-not-a-real-secret - ANALYTICS_SALT_SECRET: ci-only-not-a-real-secret - CRON_SECRET: ci-only-not-a-real-secret - NEXT_PUBLIC_SERVER_URL: http://localhost:3000 - steps: - - uses: actions/checkout@v4 - - run: apk add --no-cache libc6-compat - - run: npm ci - - run: npm run lint - - run: npx next typegen && npx tsc --noEmit - - run: npm test - - run: npm run build - env: - NODE_ENV: production - - build-and-deploy: - needs: test - runs-on: ubuntu-latest - # The anouma-runner runs as a Docker container itself (gitea/runner:3), - # with the host's docker.sock bind-mounted in but no `docker` CLI binary - # of its own — a job with no `container:` here runs directly inside that - # minimal runner image and gets "docker: command not found". - # - # A plain `docker:27-cli` image (Alpine, no Node.js) doesn't work either: - # actions/checkout@v4 is a JS action and needs a Node runtime in the job - # container, which that image doesn't have — it failed at Checkout with - # exit 127. Using a Node image as the base guarantees Node is already - # there (same reason the `test` job's node:22-alpine works), and the - # Docker CLI + ssh client are added via apt. The runner auto-mounts the - # same host docker.sock into this container (sibling containers — see - # container.docker_host in the runner's config.yaml), so `docker - # build`/`push` operate on the host's real Docker daemon. + # Node-based image (not the bare runner, not a Docker-only image) for + # the same reasons as ci.yml's build-image job: the runner needs a + # Docker CLI added via apt (it has none of its own), and actions/checkout + # needs a Node runtime that a plain docker:*-cli image doesn't have. container: image: node:22-bookworm-slim steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install Docker CLI and ssh client run: | apt-get update @@ -94,13 +61,24 @@ jobs: REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_IMAGE" -u "$REGISTRY_USERNAME" --password-stdin - - name: Build image + - name: Pull the tested image for this commit + env: + IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }} + run: | + docker pull "$IMAGE:$GITHUB_SHA" || { + echo "::error::No image found for commit $GITHUB_SHA. This tag must point at a commit that was already pushed to main and built by ci.yml's build-image job — push to main first (and wait for CI to finish) before tagging a release." >&2 + exit 1 + } + + - name: Promote image to the release tag and latest env: IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }} VERSION: ${{ steps.image.outputs.version }} - run: docker build -t "$IMAGE:$VERSION" -t "$IMAGE:latest" . + run: | + docker tag "$IMAGE:$GITHUB_SHA" "$IMAGE:$VERSION" + docker tag "$IMAGE:$GITHUB_SHA" "$IMAGE:latest" - - name: Push image + - name: Push promoted tags env: IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }} VERSION: ${{ steps.image.outputs.version }} diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 8e9eb37..dbaf71d 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -3,12 +3,18 @@ Production deployment is fully CI/CD-driven via Gitea Actions. There is no `update.sh` and the production server never builds anything — it only ever pulls a tested image from the registry. ```text -Developer → git push → Gitea → Gitea Actions - → Tests → Docker Build → Registry Push - → Release (git tag vX.Y.Z) → SSH → deploy.sh - → docker compose pull → migrate → up -d → Healthcheck → LIVE +Developer → git push main → Gitea Actions (CI) + → Tests → Docker Build → Registry Push (tagged by commit SHA) + → Release (git tag vX.Y.Z) → promote that exact image to vX.Y.Z + latest + → SSH → deploy.sh → docker compose pull → migrate → up -d → Healthcheck → LIVE ``` +Building and testing happen exactly once, on `main` — tagging a release never +rebuilds or re-tests, it only promotes the already-verified image for that +commit. Tagging a commit that hasn't been pushed to `main` (or whose CI run +hasn't finished yet) fails the release with a clear error instead of quietly +building from source and skipping the checks. + ## One-time server setup ```bash @@ -64,15 +70,17 @@ Gitea Actions takes it from there (see below). Once it's live, seed the original Runs on every push and PR: `npm ci` → lint → typecheck → test → build, on the `ubuntu-latest`-labelled runner. No database is required — every Payload-backed route in this app is `force-dynamic`, so a production build never touches Postgres. (There's no automated test suite yet — `npm test` is an honest placeholder; add a real one, e.g. vitest, whenever the project needs it, and it'll run here automatically.) +On top of that, every push to `main` that passes the checks above also builds the Docker image and pushes it to the registry tagged by commit SHA (`git.maro.run/maro/anouma:`) — this is the one and only build of that commit; nothing later rebuilds it. + ### Release (`.gitea/workflows/release.yml`) Triggered by pushing a tag matching `v*` (e.g. `v1.2.0`): -1. Re-runs the same quality gates as CI. -2. Builds the Docker image and pushes `git.maro.run/maro/anouma:vX.Y.Z` **and** `:latest`. +1. Pulls the image CI already built and pushed for that commit's SHA — fails immediately with a clear error if it isn't there (the tagged commit was never on `main`, or CI for it hasn't finished yet). +2. Retags that same image as `git.maro.run/maro/anouma:vX.Y.Z` **and** `:latest`, and pushes both. 3. SSHes into the production server as `anouma-deploy` and runs `deploy.sh deploy vX.Y.Z`. -Production always runs a specific version tag — `latest` is pushed for convenience/reference only, `deploy.sh` never uses it. +Production always runs a specific version tag — `latest` is pushed for convenience/reference only (and always points at the most recently *released* build, not just the newest commit on `main`), `deploy.sh` never uses it. ## `deploy.sh` (server-side, `/opt/anouma/deploy.sh`) @@ -190,6 +198,7 @@ Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volu | Symptom | Check | | --- | --- | | `setup.sh` fails at "Datenbank wurde nicht rechtzeitig healthy" | `docker compose logs postgres` (from `/opt/anouma`) — usually a bad `POSTGRES_PASSWORD`/`DATABASE_URI` mismatch if you hand-edited `.env` | +| Release workflow fails at "Pull the tested image" | The tagged commit was never pushed to `main`, or `main`'s CI/build-image run for it hasn't finished yet — push to `main`, wait for CI to go green, then tag | | Release workflow fails at "Deploy to production" | Check the Gitea secrets are all set correctly, and that the server's SSH port is reachable from the runner | | App container unhealthy after a deploy | `ssh` directly to the server (not via the restricted key) and run `docker compose logs app` from `/opt/anouma` | | Video calls connect but no audio/video | Likely a restrictive NAT — configure a TURN server (see above) |