CI / lint (pull_request) Successful in 55s
docker / lint (pull_request) Successful in 56s
CI / test (pull_request) Successful in 1m6s
docker / test (pull_request) Successful in 1m4s
CI / publish (pull_request) Has been skipped
docker / docker (pull_request) Successful in 41s
The Docker smoke-test ran the server container under the fixed name familiar-smoke. On the self-hosted runner the job shares the host Docker daemon, so a container left behind by a killed or re-run job (or a concurrent dev push + PR) collides on that name and 'docker run' fails with exit 125. Derive the name from the commit SHA, remove any stale container of that name before starting, and thread the name through the smoke-test and cleanup steps.
150 lines
6.4 KiB
YAML
150 lines
6.4 KiB
YAML
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 "<httpx call returning a Response>" -> 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
|