From 01a4a26414c518df224a8c684fc8140bf79a361b Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:26:52 -0300 Subject: [PATCH] ci(docker): run the image before publishing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker workflow built both images and never started a container, and never parsed a Compose file. A green run therefore only meant the Dockerfile compiled. Two defects that break the very first documented command shipped past it: `docker-compose.yml` carried a bare `environment:` key that made Compose reject the file outright (#2188), and `_embed_texts` handed chromadb `np.float32` scalars so `mine` aborted on the first drawer (#2187). Add `scripts/docker-smoke.sh`, which exercises what the README tells users to run: 1. `compose config` on docker-compose.yml and the server compose file 2. entrypoint dispatch for both `cli ...` and bare passthrough 3. `mine` a mounted directory, asserting a drawer is filed 4. `search` from a *separate* container, asserting the stored text comes back verbatim — this is the assertion that matters, since storing user words exactly is the promise the palace makes 5. a real MCP stdio JSON-RPC handshake: initialize, tools/list, and a mempalace_search call whose result must contain the drawer It asserts on returned content, not just exit codes, and lives in a script rather than inline YAML so it runs identically on a laptop: `scripts/docker-smoke.sh `. The new `smoke` job builds amd64 natively with `load: true` (buildx cannot load a multi-arch manifest) and reads the publish job's cache while writing its own scope, so an amd64-only export never lands on top of the multi-arch one. `build` now needs it, so a failing smoke test blocks publication rather than being noticed afterwards. Verified by reintroducing each defect against a real build: the compose regression fails at step 1, the embedding regression at step 3, and the current tree passes all five. Failure output is clipped to 500 columns because a rejected embedding batch otherwise prints a whole 384-dim vector on one line and buries the message. --- .github/workflows/docker-publish.yml | 41 +++++++ scripts/docker-smoke.sh | 162 +++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100755 scripts/docker-smoke.sh diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0f14322..e7612e3 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -16,8 +16,49 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: + # Run the image before anyone can publish it. A green `build` only proves the + # Dockerfile compiles — it never started a container, so defects that break + # the first documented command shipped anyway (#2187, #2188). This builds + # amd64 natively, loads it into the local daemon, and exercises the README + # paths: CLI mine + verbatim search across two containers, a real MCP stdio + # handshake, and `compose config` on both shipped Compose files. + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + # amd64 only and `load: true`: the smoke run needs a real image in the + # local daemon, and buildx cannot load a multi-arch manifest. Shares the + # `build` job's gha cache scope, so this is mostly a cache hit. + - name: Build image for smoke test + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: false + load: true + tags: mempalace:smoke + # Read the publish job's cache too (its amd64 layers are identical), + # but write to a private scope so an amd64-only export never lands on + # top of that job's multi-arch one. Same split as `scope=gpu` below. + cache-from: | + type=gha + type=gha,scope=smoke + cache-to: ${{ github.event_name != 'pull_request' && 'type=gha,mode=max,scope=smoke' || '' }} + + # Reaches Chroma's S3 once to fetch the ~80 MB embedding model, so it is + # network-dependent; that download is itself part of a user's first run. + - name: Smoke test + run: ./scripts/docker-smoke.sh mempalace:smoke + build: runs-on: ubuntu-latest + # Never publish an image the smoke test has not cleared. + needs: smoke permissions: contents: read packages: write diff --git a/scripts/docker-smoke.sh b/scripts/docker-smoke.sh new file mode 100755 index 0000000..2c732c7 --- /dev/null +++ b/scripts/docker-smoke.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# +# Smoke-test a built MemPalace image and the shipped Compose files. +# +# scripts/docker-smoke.sh [IMAGE] # default image: mempalace:smoke +# +# CI builds the container images but, until this existed, never ran one and +# never parsed a Compose file — so two defects that break the very first +# documented command shipped anyway: a `docker-compose.yml` Compose refused to +# load (#2188), and an embedding-vector bug that aborted `mine` on the first +# drawer (#2187). A build that succeeds proves the image *compiles*; it says +# nothing about whether the thing inside it works. +# +# So this exercises the paths the README actually tells users to run, and +# asserts on returned content rather than exit codes alone — the `mine` crash +# was a non-zero exit, but the verbatim read-back is what proves the palace +# really holds the text. +# +# Requires: docker (with the compose plugin) and python3. + +set -euo pipefail + +IMAGE="${1:-mempalace:smoke}" +VOLUME="mempalace-smoke-$$" +WORKDIR="$(mktemp -d)" + +# A sentence we can assert on verbatim. Storing user words exactly is the +# project's core promise, so the read-back check is the real assertion here. +NEEDLE="eleven round trips to render one screen" + +cleanup() { + docker volume rm -f "$VOLUME" >/dev/null 2>&1 || true + rm -rf "$WORKDIR" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# Dump captured output on failure. Lines are clipped because the failure this +# most often reports — a rejected embedding batch — puts a whole 384-dim vector +# repr on a single line, which buries the actual message in the CI log. +dump() { + echo "--- last 40 lines (clipped to 500 cols) ---" >&2 + printf '%s\n' "$1" | tail -40 | cut -c1-500 >&2 + echo "-------------------------------------------" >&2 +} + +echo "== 1/5 Compose files parse ==" +# A key with only comments under it parses as null and Compose rejects the +# whole file. `config` is the cheapest way to catch that class of defect. +docker compose -f docker-compose.yml config --quiet \ + || fail "docker-compose.yml is not a valid Compose file" +echo " docker-compose.yml ok" + +# The server file interpolates a mandatory token; a dummy satisfies the +# `:?` guard so the rest of the file still gets validated. +MEMPALACE_MCP_HTTP_TOKEN=smoke-token \ + docker compose -f deploy/docker-compose.server.yml config --quiet \ + || fail "deploy/docker-compose.server.yml is not a valid Compose file" +echo " deploy/docker-compose.server.yml ok" + +echo "== 2/5 CLI entrypoint dispatch ==" +# docker-entrypoint.sh routes `cli`/`mcp` keywords and forwards anything else +# to the CLI. Both forms are documented, so both are checked. +docker run --rm -v "$VOLUME:/data" "$IMAGE" cli --version >/dev/null \ + || fail "'cli --version' failed" +docker run --rm -v "$VOLUME:/data" "$IMAGE" --version >/dev/null \ + || fail "bare '--version' passthrough failed" +echo " 'cli ...' and bare passthrough both dispatch" + +echo "== 3/5 mine a mounted directory ==" +cat > "$WORKDIR/notes.md" <&1)" \ + || { dump "$mine_out"; fail "'mine /work' exited non-zero"; } + +echo "$mine_out" | grep -q "Drawers filed: 1" \ + || { dump "$mine_out"; fail "expected 'Drawers filed: 1'"; } +echo " filed 1 drawer" + +echo "== 4/5 search it back from a separate container ==" +# A new container against the same volume: proves the palace persisted and +# that the stored text is returned verbatim, not summarised. +search_out="$(docker run --rm -v "$VOLUME:/data" "$IMAGE" search "why did we move off REST" 2>&1)" \ + || { dump "$search_out"; fail "'search' exited non-zero"; } + +echo "$search_out" | grep -qF "$NEEDLE" \ + || { dump "$search_out"; fail "search did not return the drawer verbatim"; } +echo " drawer returned verbatim" + +echo "== 5/5 MCP server over stdio ==" +# The README's MCP client config runs the image with -i and speaks JSON-RPC on +# stdin/stdout. Drive a real handshake and one tool call. +mcp_out="$(printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ + '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"mempalace_search","arguments":{"query":"GraphQL"}}}' \ + | docker run -i --rm -v "$VOLUME:/data" "$IMAGE" mcp 2>/dev/null)" + +# The transcript goes to a file and the parser reads it via argv: the heredoc +# already occupies this command's stdin. +printf '%s\n' "$mcp_out" > "$WORKDIR/mcp.jsonl" + +NEEDLE="$NEEDLE" python3 - "$WORKDIR/mcp.jsonl" <<'PY' +import json, os, sys + +needle = os.environ["NEEDLE"] +seen = {} +with open(sys.argv[1], encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except ValueError: + continue # non-JSON banner lines are not our concern here + if "id" in msg: + seen[msg["id"]] = msg + + +def result(rid, what): + msg = seen.get(rid) + if msg is None: + sys.exit(f"FAIL: no JSON-RPC response for {what} (id={rid})") + if "error" in msg: + sys.exit(f"FAIL: {what} returned an error: {msg['error']}") + return msg["result"] + + +name = result(1, "initialize")["serverInfo"]["name"] +if name != "mempalace": + sys.exit(f"FAIL: initialize reported serverInfo.name={name!r}") +print(f" initialize ok (server: {name})") + +tools = result(2, "tools/list")["tools"] +if not tools: + sys.exit("FAIL: tools/list returned no tools") +print(f" tools/list ok ({len(tools)} tools)") + +call = result(3, "tools/call") +if call.get("isError"): + sys.exit(f"FAIL: mempalace_search reported isError: {call}") +text = "".join(part.get("text", "") for part in call.get("content", [])) +if needle not in text: + sys.exit("FAIL: mempalace_search did not return the drawer verbatim") +print(" mempalace_search ok (drawer returned verbatim)") +PY + +echo +echo "docker smoke: all checks passed ($IMAGE)"