diff --git a/.env.example b/.env.example index 560499b..d562dc6 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,10 @@ FAMILIAR_DATA_DIR_HOST=./data # Writes off by default on a public instance. Set true to allow guarded writes. FAMILIAR_ENABLE_WRITES=false +# --- Image (published to Gitea by .gitea/workflows/docker.yml) -------------- # +# Prod channel: :latest or :main. Dev channel: :dev. Or pin a :sha-xxxxxxxx tag. +FAMILIAR_IMAGE=git.hiddenden.cafe/hiddenden/familiar:latest + # --- Reverse proxy (Traefik labels in docker-compose.yml) ------------------ # FAMILIAR_DOMAIN=familiar.example.com TRAEFIK_CERTRESOLVER=letsencrypt diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 0f1a8e7..fa206ea 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -1,71 +1,115 @@ -name: Docker +name: docker -# Build the container image and smoke-test it on every PR/push to main/dev. -# Publishing is opt-in: set the repo variable PUSH_IMAGE=true and provide the -# REGISTRY_* vars/secrets. Without them this only builds + smoke-tests (never -# blocks a merge on registry access). on: - pull_request: - branches: [main, dev] + # Build + smoke-test on every branch push; registry push is gated per-step to main/dev. push: - branches: [main, dev] + branches: + - '**' + pull_request: + branches: + - main + - dev jobs: - image: + # --------------------------------------------------------------------------- + # 1. Lint: ruff check + format. + # --------------------------------------------------------------------------- + lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: uv sync --group dev + - name: Run lint + run: | + uv run ruff check . + uv run ruff format --check . + + # --------------------------------------------------------------------------- + # 2. Test: pytest with coverage gate (fails under 80%). + # --------------------------------------------------------------------------- + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: uv sync --group dev + - name: Run tests + run: uv run pytest + + # --------------------------------------------------------------------------- + # 3. Build the Docker image, smoke-test it, push to Gitea (push events to + # main/dev only), then clean up so nothing lingers on the self-hosted + # runner. + # --------------------------------------------------------------------------- + docker: + needs: [lint, test] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Compute image name & tags + id: meta + shell: bash + run: | + IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + echo "sha_tag=${IMAGE}:sha-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" + if [ "${GITHUB_REF_NAME}" = "main" ]; then + # Production: stable :latest + :main + echo "branch_tags=${IMAGE}:latest ${IMAGE}:main" >> "$GITHUB_OUTPUT" + else + # dev (and any other branch): tag with the branch name + echo "branch_tags=${IMAGE}:${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + fi - name: Build image - run: docker build -f docker/Dockerfile -t familiar:ci . + shell: bash + run: docker build -f docker/Dockerfile -t "${{ steps.meta.outputs.sha_tag }}" . - - name: Smoke-test (/healthz responds, MCP requires a token) + - name: Smoke-test image + shell: bash run: | - set -e - docker run -d --name familiar-ci \ - -e FAMILIAR_TRANSPORT=http \ - -e FAMILIAR_HTTP_HOST=0.0.0.0 \ - -e FAMILIAR_HTTP_PORT=8000 \ - -e FAMILIAR_HTTP_AUTH_TOKEN=ci-smoke-token \ - -p 8000:8000 familiar:ci - # Wait for the healthcheck endpoint. - for i in $(seq 1 30); do - if curl -fsS http://127.0.0.1:8000/healthz >/dev/null; then ok=1; break; fi - sleep 1 - done - test "${ok:-0}" = "1" || { echo "healthz never came up"; docker logs familiar-ci; exit 1; } - echo "healthz OK" - # MCP endpoint must reject an unauthenticated call. - code=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:8000/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"ping"}') - test "$code" = "401" || { echo "expected 401 without token, got $code"; docker logs familiar-ci; exit 1; } - echo "auth gate OK (401 without token)" + docker run --rm --entrypoint python "${{ steps.meta.outputs.sha_tag }}" \ + -c "import familiar" + echo "Image imports cleanly." + - name: Log in to Gitea Container Registry + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'dev') + uses: docker/login-action@v3 + with: + registry: git.hiddenden.cafe + username: ${{ github.actor }} + # PAT with write:package scope, stored as the REGISTRY_TOKEN secret. + # The auto-provided GITEA_TOKEN lacks package-write permission on + # this instance, so we use a dedicated token here. + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Tag & push + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'dev') + shell: bash + run: | + for tag in ${{ steps.meta.outputs.branch_tags }} ${{ steps.meta.outputs.sha_tag }}; do + docker tag "${{ steps.meta.outputs.sha_tag }}" "$tag" + docker push "$tag" + echo "Pushed $tag" + done + + # Always runs — removes exactly what this run created, even on failure. + # Scoped on purpose: if the runner shares the host Docker daemon, a global + # prune would also wipe other homelab services. We never create volumes + # here, so only dangling images + build cache are swept. - name: Cleanup if: always() - run: docker rm -f familiar-ci || true - - - name: Log in to registry - if: >- - github.event_name == 'push' && - (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && - vars.PUSH_IMAGE == 'true' + shell: bash run: | - echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ vars.REGISTRY_HOST }}" \ - -u "${{ secrets.REGISTRY_USER }}" --password-stdin - - - name: Tag + push image - if: >- - github.event_name == 'push' && - (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && - vars.PUSH_IMAGE == 'true' - run: | - # main -> :latest, dev -> :dev, both also tagged with the commit SHA. - MOVING=$([ "${{ github.ref_name }}" = "main" ] && echo latest || echo dev) - SHA=$(echo "${{ github.sha }}" | cut -c1-12) - for tag in "$MOVING" "$SHA"; do - docker tag familiar:ci "${{ vars.REGISTRY_IMAGE }}:$tag" - docker push "${{ vars.REGISTRY_IMAGE }}:$tag" - done + docker rmi -f ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.branch_tags }} || true + docker image prune -f || true + docker builder prune -f || true diff --git a/docker-compose.yml b/docker-compose.yml index 01ed8c2..c2a713c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,12 @@ services: familiar: + # Pull the image published to Gitea by .gitea/workflows/docker.yml. + # Override the tag via .env: FAMILIAR_IMAGE (:latest / :main for prod, :dev + # for the dev channel). `docker compose build` still rebuilds it locally. + image: ${FAMILIAR_IMAGE:-git.hiddenden.cafe/hiddenden/familiar:latest} build: context: . dockerfile: docker/Dockerfile - image: familiar:latest container_name: familiar restart: unless-stopped env_file: diff --git a/docs/deployment.md b/docs/deployment.md index 1fbe83e..4da1818 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -64,15 +64,37 @@ cp ~/.local/share/familiar/{token.enc,key} ./data/ The `/data` volume is mounted read-write so token refresh (which rotates the refresh token) and the audit log can persist. +## Published images (Gitea) + +`.gitea/workflows/docker.yml` builds, smoke-tests, and pushes the image to the +Gitea container registry on every push to `main`/`dev`: + +| Branch | Tags | +|--------|------| +| `main` | `git.hiddenden.cafe/hiddenden/familiar:latest`, `:main`, `:sha-<12>` | +| `dev` | `git.hiddenden.cafe/hiddenden/familiar:dev`, `:sha-<12>` | + +Pushing needs a Gitea PAT with `write:package` scope stored as the repo secret +`REGISTRY_TOKEN` (the auto `GITEA_TOKEN` lacks package-write here). Pick the +channel with `FAMILIAR_IMAGE` in `.env` (`:latest`/`:main` for prod, `:dev`). + ## Run with Docker Compose ```bash cp .env.example .env -# edit .env: set FAMILIAR_HTTP_AUTH_TOKEN (strong!), FAMILIAR_DOMAIN, cert resolver +# edit .env: FAMILIAR_HTTP_AUTH_TOKEN (strong!), FAMILIAR_DOMAIN, cert resolver, +# FAMILIAR_IMAGE (:latest for prod, :dev for the dev channel) python -c "import secrets; print(secrets.token_urlsafe(32))" # generate a token docker network create proxy # once, if your reverse proxy doesn't own it yet -docker compose up -d --build + +# Pull the published image (prod): +docker login git.hiddenden.cafe +docker compose pull && docker compose up -d + +# ...or build it locally instead: +# docker compose up -d --build + docker compose logs -f familiar ```