From eb2922f63c1621e46e43c0f71128712c4a251251 Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 4 Jul 2026 19:26:17 +0200 Subject: [PATCH] ci(docker): build, smoke-test, and publish the remote-HTTP image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New .gitea/workflows/docker.yml (alongside the untouched ci.yml wheel publish). Runs lint + test, then builds the image and smoke-tests it — non-root uid, /healthz 200, MCP 401 without a token and 200 with a valid bearer. On a merge to main/dev it pushes to git.hiddenden.cafe/hiddenden/familiar (main -> :latest + :main, dev -> :dev, plus :sha-<12>) via the existing REGISTRY_TOKEN, then does a scoped cleanup. The two workflows are independent and don't interfere. --- .gitea/workflows/docker.yml | 131 ++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 .gitea/workflows/docker.yml diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..38079d8 --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -0,0 +1,131 @@ +name: docker + +# Build + smoke-test the remote-HTTP (`familiar serve`) image on every push/PR; +# push to the Gitea container registry only on a real merge to main/dev. +# Runs alongside ci.yml (which publishes the Python wheel) — they don't overlap. +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + 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: Ruff (lint + format check) + run: | + uv run ruff check . + uv run ruff format --check . + + 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 with coverage (fails under 80%) + run: uv run pytest + + docker: + needs: [lint, test] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - 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 + echo "branch_tags=${IMAGE}:latest ${IMAGE}:main" >> "$GITHUB_OUTPUT" + else + echo "branch_tags=${IMAGE}:${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + fi + + - name: Build image + shell: bash + run: docker build -f docker/Dockerfile -t "${{ steps.meta.outputs.sha_tag }}" . + + - name: Smoke-test image + shell: bash + run: | + set -euo pipefail + IMG="${{ steps.meta.outputs.sha_tag }}" + TOKEN="smoke-$(date +%s)" + + # 1. Runs as a non-root user (uid 1000). + uid=$(docker run --rm --entrypoint id "$IMG" -u) + echo "container uid: $uid" + [ "$uid" = "1000" ] || { echo "::error::image runs as uid $uid, expected 1000"; exit 1; } + + # 2. Boot the server and probe it over HTTP. + docker run -d --name familiar-smoke -e FAMILIAR_HTTP_TOKEN="$TOKEN" -p 18080:8080 "$IMG" + for i in $(seq 1 30); do + if curl -fsS "http://127.0.0.1:18080/healthz" >/dev/null 2>&1; then break; fi + sleep 1 + done + + echo "== /healthz (expect 200) ==" + code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:18080/healthz") + [ "$code" = "200" ] || { echo "::error::/healthz returned $code"; docker logs familiar-smoke; exit 1; } + + echo "== MCP without token (expect 401) ==" + code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:18080/mcp" \ + -H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}') + [ "$code" = "401" ] || { echo "::error::unauthenticated MCP returned $code"; exit 1; } + + echo "== MCP with valid token (expect 200) ==" + code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:18080/mcp" \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"ci","version":"1"}}}') + [ "$code" = "200" ] || { echo "::error::authenticated MCP returned $code"; docker logs familiar-smoke; exit 1; } + + echo "Image smoke-test passed." + + - 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 (the auto GITEA_TOKEN lacks it 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 only what this run created (the self-hosted runner + # may share the host Docker daemon, so no global prune of other services). + - name: Cleanup + if: always() + shell: bash + run: | + docker rm -f familiar-smoke || true + docker rmi -f ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.branch_tags }} || true + docker image prune -f || true + docker builder prune -f || true