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" # Per-commit container name: the self-hosted runner shares the host # Docker daemon, so a fixed name collides across concurrent/re-run jobs. echo "container=familiar-smoke-${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 }}" CONTAINER="${{ steps.meta.outputs.container }}" TOKEN="smoke-$(date +%s)" # Remove any container left behind by a killed/re-run job before we # reuse the name (shared host daemon → stale containers can survive). docker rm -f "$CONTAINER" >/dev/null 2>&1 || true # 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 from *inside* its own container via # `docker exec`. On self-hosted runners the job step runs in a container # that shares the host's Docker socket but sits on a different network, # so it can reach neither a published host port (-p lands on the runner # host, not this step's netns) nor the server container's bridge IP # (different network → connect hangs). `docker exec` goes through the # socket and runs the probe in the server's own netns, where # 127.0.0.1:8080 always works regardless of runner topology. The image # ships python + httpx, so no curl or extra pull is needed. docker run -d --name "$CONTAINER" -e FAMILIAR_HTTP_TOKEN="$TOKEN" "$IMG" # probe "" -> prints the status code probe() { docker exec "$CONTAINER" python -c "import httpx; print(($1).status_code)"; } for i in $(seq 1 30); do if docker exec "$CONTAINER" python -c \ "import httpx; httpx.get('http://127.0.0.1:8080/healthz', timeout=5)" >/dev/null 2>&1; then break fi sleep 1 done echo "== /healthz (expect 200) ==" code=$(probe "httpx.get('http://127.0.0.1:8080/healthz', timeout=10)") [ "$code" = "200" ] || { echo "::error::/healthz returned $code"; docker logs "$CONTAINER"; exit 1; } echo "== MCP without token (expect 401) ==" code=$(probe "httpx.post('http://127.0.0.1:8080/mcp', timeout=10, headers={'Accept':'application/json, text/event-stream','Content-Type':'application/json'}, json={'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=$(probe "httpx.post('http://127.0.0.1:8080/mcp', timeout=10, headers={'Authorization':'Bearer $TOKEN','Accept':'application/json, text/event-stream','Content-Type':'application/json'}, json={'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 "$CONTAINER"; 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 ${{ steps.meta.outputs.container }} || 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