From e898992a3c9758285ab81869a5904a340e66d14e Mon Sep 17 00:00:00 2001 From: slmingol Date: Fri, 14 Aug 2026 23:29:52 -0400 Subject: [PATCH 1/3] feat: add Docker/Podman containerized setup with GHCR publishing (closes #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.yaml: two services — graphify (CLI indexing, profile:cli) and mcp (HTTP MCP server); prefers ghcr.io/graphify-labs/graphify:latest when present, falls back to local build; GRAPHIFY_API_KEY/PORT/IMAGE env vars; SELinux :Z guidance; TCP socket healthcheck (avoids SSE 406 false negatives); user:0 for podman rootless bind-mount compat - Makefile: auto-detects docker vs podman (RUNTIME override); targets: pull, build, up, down, index [SRC=...], logs; up/index try GHCR pull first, fall back to local build; mkdir -p graphify-out guard - .github/workflows/docker.yml: native runners (ubuntu-latest for amd64, ubuntu-24.04-arm for arm64) — avoids broken QEMU cross-compiled C extensions; pushes :latest, semver, and :sha- tags; GHA layer cache Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/docker.yml | 120 +++++++++++++++++++++++++++++++++++ Makefile | 47 ++++++++++++++ docker-compose.yaml | 101 +++++++++++++++++++++++++++++ 3 files changed, 268 insertions(+) create mode 100644 .github/workflows/docker.yml create mode 100644 Makefile create mode 100644 docker-compose.yaml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..677f6e2e8 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,120 @@ +name: Build and push Docker image to GHCR + +# Builds linux/amd64 and linux/arm64 on NATIVE runners (not QEMU) so that +# C-extension packages (cffi, cryptography, tree-sitter) compile correctly on +# each arch. The two platform images are pushed as digests, then merged into +# a single multi-arch manifest. + +on: + push: + branches: ["v8"] + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + packages: write # required to push to ghcr.io + +jobs: + build: + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + + # Push a platform-specific digest (no tag yet — the merge job tags it). + - name: Build and push digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + push: true + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=${{ matrix.platform }} + + # Pass the digest to the merge job via an artifact. + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + - uses: actions/upload-artifact@v4 + with: + name: digest-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} + path: /tmp/digests/* + retention-days: 1 + + merge: + name: Merge into multi-arch manifest + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digest-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + # latest on every v8 push + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/v8' }} + # semver tags on release (e.g. 0.9.28, 0.9) + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + # short-sha for traceability + type=sha,prefix=sha- + + - name: Create and push multi-arch manifest + working-directory: /tmp/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) + + - name: Inspect final manifest + run: | + docker buildx imagetools inspect ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.version }} diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..632765cd7 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +# Makefile — build and run graphify via Docker or Podman (issue #1) +# +# Auto-detects docker; falls back to podman. +# Override: make RUNTIME=podman build + +RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null) +COMPOSE := $(RUNTIME) compose + +# Directory to index (override with: make index SRC=/path/to/project) +SRC ?= $(CURDIR) + +.PHONY: pull build up down index logs + +## Pull the pre-built image from GHCR (fastest path — no local build needed) +pull: + $(COMPOSE) pull + +## Build the graphify image locally from source +build: + $(COMPOSE) build + +## Pull from GHCR if available, otherwise build locally, then start the MCP server +## Set GRAPHIFY_API_KEY in your shell before running. +up: + $(COMPOSE) pull mcp 2>/dev/null || $(COMPOSE) build mcp + $(COMPOSE) up mcp + +## Stop the MCP HTTP server +down: + $(COMPOSE) down + +## Index SRC into ./graphify-out/graph.json (code-only, no API key needed) +## For full semantic extraction pass your key: make index ANTHROPIC_API_KEY=sk-... +## Usage: make index OR make index SRC=/path/to/project +index: + mkdir -p ./graphify-out + $(COMPOSE) --profile cli pull graphify 2>/dev/null || $(COMPOSE) --profile cli build graphify + $(COMPOSE) --profile cli run --rm \ + -v "$(SRC):/src:ro" \ + $(if $(ANTHROPIC_API_KEY),-e ANTHROPIC_API_KEY=$(ANTHROPIC_API_KEY)) \ + $(if $(OPENAI_API_KEY),-e OPENAI_API_KEY=$(OPENAI_API_KEY)) \ + $(if $(GEMINI_API_KEY),-e GEMINI_API_KEY=$(GEMINI_API_KEY)) \ + graphify extract /src --code-only --output /data + +## Tail MCP server logs +logs: + $(COMPOSE) logs -f mcp diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 000000000..4fcbd160b --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,101 @@ +# docker-compose.yaml — run graphify without a local Python install (issue #1) +# Works with both `docker compose` and `podman compose`. +# Use the Makefile targets for convenience: `make pull`, `make build`, `make index`, `make up`. +# +# Image resolution order +# ---------------------- +# When both `image:` and `build:` are set, compose uses the local image if present +# (pulled from GHCR or previously built), otherwise pulls from GHCR. +# To force a local build: make build (docker compose build) +# To pull the latest release: make pull (docker compose pull) +# +# Quick start +# ----------- +# 1. Pull from GHCR (no build required): +# make pull # or: docker compose pull +# +# 2. Index a codebase: +# make index # indexes the current directory +# make index SRC=/path/to/project # indexes a different directory +# +# 3. Start the MCP HTTP server: +# GRAPHIFY_API_KEY=secret make up # or: docker compose up mcp +# +# 4. Point your AI assistant at http://localhost:8080/mcp with the same key. +# +# Environment variables +# --------------------- +# GRAPHIFY_API_KEY — Bearer token for the HTTP server (required for `mcp` service) +# GRAPHIFY_PORT — override the host-side port (default: 8080) +# GRAPHIFY_IMAGE — override the GHCR image (default: ghcr.io/slmingol/graphify:latest) +# +# SELinux note (RHEL / Fedora / CoreOS) +# -------------------------------------- +# If volume mounts fail with "permission denied", append :Z to each volume path: +# ./graphify-out:/data:Z,ro +# ./:/src:ro,Z + +x-image: &image ${GRAPHIFY_IMAGE:-ghcr.io/slmingol/graphify:latest} + +services: + # --------------------------------------------------------------------------- + # graphify CLI — index a codebase, run queries, explain nodes, etc. + # /src is your project (read-only); output lands in ./graphify-out/graph.json. + # + # Code-only index (no API key needed): + # docker compose --profile cli run --rm graphify extract /src --code-only --output /data + # + # Full semantic index (pass LLM key via -e): + # docker compose --profile cli run --rm -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \ + # graphify extract /src --backend claude --output /data + # + # Query / explain (graph must exist first): + # docker compose --profile cli run --rm graphify query "who calls authenticate?" + # docker compose --profile cli run --rm graphify explain "APIRouter" + # docker compose --profile cli run --rm graphify path "FastAPI" "ModelField" + # + # user: "0" — run as root inside the container so the host-user-owned files + # on the bind-mount are readable under podman rootless user-namespace mapping. + # --------------------------------------------------------------------------- + graphify: + image: *image + build: . + user: "0" + volumes: + - ./graphify-out:/data/graphify-out + - ./:/src:ro + profiles: + - cli + + # --------------------------------------------------------------------------- + # MCP HTTP server — expose a pre-built graph.json to your AI assistant. + # Requires ./graphify-out/graph.json to exist (build it with the service above). + # --------------------------------------------------------------------------- + mcp: + image: *image + build: . + entrypoint: ["graphify-mcp"] + command: + - /data/graph.json + - --transport + - http + - --host + - "0.0.0.0" + - --port + - "8080" + ports: + - "${GRAPHIFY_PORT:-8080}:8080" + environment: + GRAPHIFY_API_KEY: ${GRAPHIFY_API_KEY:-} + volumes: + - ./graphify-out:/data:ro + restart: unless-stopped + healthcheck: + # TCP socket check — works regardless of HTTP response code or SSE framing. + test: + - "CMD-SHELL" + - "python -c \"import socket; socket.create_connection(('localhost', 8080), timeout=2).close()\"" + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s From 751c625f63f3b24d0d5f7f36de6dee64f104e650 Mon Sep 17 00:00:00 2001 From: slmingol Date: Fri, 14 Aug 2026 23:29:58 -0400 Subject: [PATCH 2/3] docs: document container setup in README Expand the "Shared HTTP server" section with a new "Running in a container" subsection covering: GHCR pull (no local Python install), make quick start, docker/podman compose examples, bare docker/podman commands, Podman rootless and SELinux notes, GRAPHIFY_IMAGE override. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a4f188f46..aa563572f 100644 --- a/README.md +++ b/README.md @@ -472,19 +472,76 @@ The MCP server gives your assistant structured access: `query_graph`, `get_node` | `--stateless` | off | No per-session state (for load-balanced / CI deployments) | | `--session-timeout` | `3600` | Reap idle stateful sessions after N seconds (`0` disables) | -The default `127.0.0.1` bind is loopback-only. Set `--host 0.0.0.0` **and** `--api-key` together when exposing on a shared host. Run it in a container: - -```bash -docker build -t graphify . -docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data" graphify \ - /data/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET" -``` +The default `127.0.0.1` bind is loopback-only. Set `--host 0.0.0.0` **and** `--api-key` together when exposing on a shared host. > **WSL / Linux note:** Ubuntu ships `python3`, not `python`. Use a venv to avoid conflicts: > ```bash > python3 -m venv .venv && .venv/bin/pip install "graphifyy[mcp]" > ``` +### Running in a container + +A pre-built multi-platform image (`linux/amd64` + `linux/arm64`) is published to GHCR on every release — no local Python install required. Works with Docker and Podman. + +**Quick start with `make` (recommended):** + +```bash +# Pull from GHCR — no build step needed +make pull + +# Index a codebase (mounts current directory read-only) +make index + +# Index a different directory +make index SRC=/path/to/project + +# Start the MCP HTTP server +GRAPHIFY_API_KEY=secret make up +``` + +**Or use `docker compose` / `podman compose` directly:** + +```bash +# Pull the pre-built image +docker compose pull # or: podman compose pull + +# Code-only index (no API key needed) +docker compose --profile cli run --rm graphify extract /src --code-only --output /data + +# Full semantic index (pass your LLM key) +docker compose --profile cli run --rm \ + -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ + graphify extract /src --backend claude --output /data + +# Start the MCP HTTP server +GRAPHIFY_API_KEY=secret docker compose up mcp +``` + +**Or bare `docker` / `podman` (no compose):** + +```bash +# Code-only index +docker run --rm \ + -v "$(pwd):/src:ro" \ + -v "$(pwd)/graphify-out:/data/graphify-out" \ + ghcr.io/graphify-labs/graphify:latest \ + extract /src --code-only --output /data + +# Serve +docker run -p 8080:8080 \ + -v "$(pwd)/graphify-out:/data:ro" \ + -e GRAPHIFY_API_KEY="$SECRET" \ + --entrypoint graphify-mcp \ + ghcr.io/graphify-labs/graphify:latest \ + /data/graph.json --transport http --host 0.0.0.0 +``` + +Point your AI assistant at `http://localhost:8080/mcp` with the same key. + +> **Podman note:** `make` and `docker-compose.yaml` auto-detect `podman` when `docker` is not in `PATH`. On SELinux systems (RHEL, Fedora, CoreOS) append `:Z` to volume paths if you see permission errors. + +> **Override the image:** set `GRAPHIFY_IMAGE=ghcr.io/your-org/graphify:custom` to use a custom registry or tag in compose. Set `RUNTIME=podman` to force Podman in the Makefile. + --- ## Environment variables From 6790fb491c2f4cf165db0e28ea4699e7d8eed929 Mon Sep 17 00:00:00 2001 From: slmingol Date: Sun, 16 Aug 2026 09:40:55 -0400 Subject: [PATCH 3/3] fix(Dockerfile): generalize entrypoint + pin mcp/cryptography for ARM64 - Restore graphify-mcp as default entrypoint (backward compat with pre-PR bare `docker run` usage); docker-compose.yaml graphify CLI service overrides to `graphify` for indexing - Pin mcp<2.0.0: mcp 2.0.0 removed mcp.types.AnyUrl which graphify.serve imports for the HTTP transport (ImportError at startup) - Pin cryptography<42: cryptography 42+ ships a Rust _openssl binding that crashes with SIGILL (exit 132) on ARM64 container runtimes (podman on Apple Silicon); 41.x is cffi-based and unaffected - Add `make help` as default target with colorized output (targets, variables, examples, auto-detected runtime) Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 33 +++++++++++++++++++++++++++------ Makefile | 39 ++++++++++++++++++++++++++++++++++++++- docker-compose.yaml | 2 +- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index a313833c5..8b80f2c5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,24 @@ -# graphify MCP server as a shared HTTP service (issue #1143). +# graphify MCP server as a shared HTTP service (issue #1, #1143). +# Works with both Docker and Podman. # -# Build: docker build -t graphify . -# Run: docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data" graphify \ -# /data/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET" +# Recommended: use the Makefile or docker-compose.yaml instead of bare commands. +# make build # build the image +# make index # index the current directory (uses graphify CLI) +# make up # start the MCP HTTP server +# +# Manual build: +# docker build -t graphify . # Docker +# podman build -t graphify . # Podman +# +# Run MCP HTTP server (default): +# docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data:ro" \ +# -e GRAPHIFY_API_KEY="$SECRET" graphify \ +# /data/graph.json --transport http --host 0.0.0.0 +# +# Code-only index (override entrypoint to graphify CLI): +# docker run --rm --entrypoint graphify \ +# -v "$(pwd):/src:ro" -v "$(pwd)/graphify-out:/data/graphify-out" graphify \ +# extract /src --code-only --output /data # # Builds from source so the image includes the Streamable HTTP transport even # before it lands on PyPI. The graph.json is mounted at runtime (-v), never @@ -13,7 +29,12 @@ WORKDIR /app COPY . /app # The [mcp] extra pulls mcp + starlette + uvicorn, which the HTTP transport needs. -RUN pip install --no-cache-dir ".[mcp]" +# Pin mcp<2.0.0: mcp 2.0.0 removed mcp.types.AnyUrl which graphify.serve imports, +# and introduced pyjwt[crypto] -> cryptography (Rust) which crashes with SIGILL on +# some ARM64 container runtimes. mcp 1.x avoids both issues. +RUN pip install --no-cache-dir ".[mcp]" \ + && pip install --no-cache-dir "mcp<2.0.0" \ + && pip install --no-cache-dir "cryptography>=41,<42" # Run as a non-root user — the server is network-exposed. RUN useradd --create-home --uid 10001 graphify @@ -21,5 +42,5 @@ USER graphify EXPOSE 8080 -ENTRYPOINT ["python", "-m", "graphify.serve"] +ENTRYPOINT ["graphify-mcp"] CMD ["/data/graph.json", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"] diff --git a/Makefile b/Makefile index 632765cd7..e4cd00ae6 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,44 @@ COMPOSE := $(RUNTIME) compose # Directory to index (override with: make index SRC=/path/to/project) SRC ?= $(CURDIR) -.PHONY: pull build up down index logs +# ANSI colors +BOLD := \033[1m +CYAN := \033[36m +GREEN := \033[32m +YELLOW := \033[33m +DIM := \033[2m +RESET := \033[0m + +.PHONY: help pull build up down index logs +.DEFAULT_GOAL := help + +## Show this help message +help: + @printf "\n$(BOLD)$(CYAN)graphify$(RESET) — containerized knowledge graph for your codebase\n" + @printf "$(DIM)Runtime: $(RUNTIME)$(RESET)\n\n" + @printf "$(BOLD)$(GREEN)Quick start$(RESET)\n" + @printf " $(CYAN)make pull$(RESET) then $(CYAN)make index$(RESET) then $(CYAN)GRAPHIFY_API_KEY=secret make up$(RESET)\n\n" + @printf "$(BOLD)$(GREEN)Targets$(RESET)\n" + @printf " $(BOLD)$(YELLOW)%-10s$(RESET) %s\n" "pull" "Pull pre-built image from GHCR (no local build needed)" + @printf " $(BOLD)$(YELLOW)%-10s$(RESET) %s\n" "build" "Build image locally from source" + @printf " $(BOLD)$(YELLOW)%-10s$(RESET) %s\n" "index" "Index SRC dir into ./graphify-out/graph.json (code-only)" + @printf " $(BOLD)$(YELLOW)%-10s$(RESET) %s\n" "up" "Start the MCP HTTP server (pull or build first)" + @printf " $(BOLD)$(YELLOW)%-10s$(RESET) %s\n" "down" "Stop the MCP HTTP server" + @printf " $(BOLD)$(YELLOW)%-10s$(RESET) %s\n" "logs" "Tail MCP server logs" + @printf "\n$(BOLD)$(GREEN)Variables$(RESET)\n" + @printf " $(CYAN)SRC$(RESET) Directory to index $(DIM)(default: \$$PWD)$(RESET)\n" + @printf " $(CYAN)RUNTIME$(RESET) docker or podman $(DIM)(default: auto-detect)$(RESET)\n" + @printf " $(CYAN)GRAPHIFY_API_KEY$(RESET) Bearer token for MCP server $(DIM)(required for \`make up\`)$(RESET)\n" + @printf " $(CYAN)GRAPHIFY_PORT$(RESET) Host port for MCP server $(DIM)(default: 8080)$(RESET)\n" + @printf " $(CYAN)GRAPHIFY_IMAGE$(RESET) Override GHCR image $(DIM)(default: ghcr.io/slmingol/graphify:latest)$(RESET)\n" + @printf " $(CYAN)ANTHROPIC_API_KEY$(RESET) LLM key for semantic index $(DIM)(optional)$(RESET)\n" + @printf "\n$(BOLD)$(GREEN)Examples$(RESET)\n" + @printf " $(DIM)# Index a different project$(RESET)\n" + @printf " make index SRC=/path/to/project\n\n" + @printf " $(DIM)# Semantic extraction with Claude$(RESET)\n" + @printf " make index ANTHROPIC_API_KEY=sk-...\n\n" + @printf " $(DIM)# Use podman explicitly$(RESET)\n" + @printf " make RUNTIME=podman up\n\n" ## Pull the pre-built image from GHCR (fastest path — no local build needed) pull: diff --git a/docker-compose.yaml b/docker-compose.yaml index 4fcbd160b..a8d29ba95 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -60,6 +60,7 @@ services: graphify: image: *image build: . + entrypoint: ["graphify"] user: "0" volumes: - ./graphify-out:/data/graphify-out @@ -74,7 +75,6 @@ services: mcp: image: *image build: . - entrypoint: ["graphify-mcp"] command: - /data/graph.json - --transport