Files
lean-bench/.gitea/workflows/docker.yml
T
claude-bot c20121ebe4
ci / test (push) Successful in 1m13s
ci / lean-project (push) Successful in 7s
docker / image (push) Failing after 47m48s
Rebuild lean-bench: pages in, verified indexed Lean out
Replaces the previous tree entirely. The pipeline is four separable pieces, each
testable on its own:

1. leanbench/llm.py      provider-neutral OpenAI-compatible connector, stdlib only,
                        with retries, JSON coercion and a scripted client so the
                        rest of the code is testable without a network.
2. leanbench/detect.py   per-page detection of formalizable mathematics. Case 1 is
                        mathematics asserted as fact (definition/theorem/lemma/...,
                        deliberately not sub-classified); case 2 is a problem left
                        open to the reader. Hits come back as spans -- start page
                        and index, end page and index -- so several per page is
                        normal. A hit cut off by the page end makes the detector
                        re-read that page together with the next, repeatedly, so an
                        item spread over three pages is found whole.
                        Offsets come from verbatim quotes resolved by
                        leanbench/anchors.py (exact -> whitespace-insensitive ->
                        fuzzy), because models quote well and count badly.
3. leanbench/formalize.py + leanbench/verify.py
                        each span becomes one Lean 4 declaration, judged by the real
                        compiler: full elaboration for case 1, statement legality
                        only for case 2, which has no proof to check. Lean's own
                        diagnostics feed back for a repair attempt. "verified" (Lean
                        accepted it) and "proved" (accepted with no sorry) are kept
                        strictly apart.
4. leanbench/db.py       indexed SQLite plus JSONL export. Every item is indexed by
                        the types of its inputs, or by its own type when it is not a
                        proof, and records the source book (sha256) and the page it
                        starts on -- so `drop --source H --pages A-B` followed by a
                        re-ingest redoes exactly those pages and nothing else.

Infrastructure:

- lean/            Lake project pinned to Mathlib.
- docker/          image carrying Lean 4 and a prebuilt Mathlib. The Mathlib
                   revision and the toolchain are resolved against each other at
                   build time rather than both hardcoded, so the build does not rot.
                   One Docker layer per Lean step, each long one under `timeout`:
                   the gigabyte cache fetch earns a layer that a later failure never
                   invalidates, and a stalled step fails with a message instead of
                   sitting silent for an hour. There is deliberately no `lake build`
                   of our own library -- verification runs `lake env lean` against
                   Mathlib's oleans and needs nothing else, and building the library
                   was pure cost. docker/smoke.py runs the whole pipeline inside the
                   image against the real compiler, with the LLM scripted.
- .gitea/workflows ci (lint + tests in a venv, since the runner image's Python is
                   Debian-managed and refuses installs into itself), docker (build,
                   verify, optionally push), ingest (submit a book by hand). The
                   docker build runs with --progress=plain, because BuildKit's
                   default renderer collapses a long step into one rewritten line
                   and a stall then looks exactly like progress.

The docker workflow is not a throwaway build: it leaves the image, the layer cache,
an idle `lean-bench` container and the `lean-bench-data` / `lean-bench-books`
volumes in place on the runner, and never prunes, so the environment is there to be
used afterwards. Pushing to the registry is optional and cannot fail the build --
Gitea's container registry does not accept the built-in Actions token, so that needs
a REGISTRY_TOKEN secret with write:package.

58 tests; they need neither network nor Lean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ci5T3gWDhhmqDec3e1XtYZ
2026-09-19 08:27:50 +00:00

230 lines
9.4 KiB
YAML

name: docker
# Builds the Lean 4 + Mathlib image and LEAVES IT IN PLACE on the runner host.
#
# This is not a throwaway test build. When the job finishes, the runner still has:
# * the image, tagged lean-bench:latest and lean-bench:sha-<commit>
# * the Docker layer cache, so the next build reuses Mathlib instead of
# re-downloading it (nothing here ever calls `docker system prune`)
# * a long-lived container `lean-bench`, idle and ready for `docker exec`
# * named volumes `lean-bench-data` (the item database) and `lean-bench-books`
#
# so you can submit a book straight afterwards:
# docker exec -e DASHSCOPE_API_KEY=sk-... lean-bench \
# lean-bench ingest /books/your-book.pdf --pages 30-60
#
# Pushing to the registry is optional and never fails the build: the built-in
# Actions token is not accepted by Gitea's container registry, so add a personal
# access token with write:package as the REGISTRY_TOKEN secret to enable it.
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
inputs:
mathlib_rev:
description: "Mathlib revision (tag, branch, commit, or 'auto')"
required: false
default: "auto"
push_image:
description: "Push to the registry if a token is configured"
required: false
default: "true"
refresh_service:
description: "Recreate the long-lived lean-bench container even if one is running"
required: false
default: "false"
no_cache:
description: "Build without the layer cache (slow: refetches Mathlib)"
required: false
default: "false"
env:
REGISTRY: gitea.entroia.com
IMAGE_NAME: lean-bench
LOCAL_IMAGE: lean-bench
SERVICE_CONTAINER: lean-bench
DATA_VOLUME: lean-bench-data
BOOKS_VOLUME: lean-bench-books
jobs:
image:
runs-on: ubuntu-latest
timeout-minutes: 150
steps:
- uses: actions/checkout@v4
- name: Check the runner can build images
run: |
set -eu
if ! command -v docker >/dev/null; then
echo "::error::No docker CLI on this runner. Give the act_runner the host"
echo "::error::docker socket (-v /var/run/docker.sock:/var/run/docker.sock)"
echo "::error::or run it with the docker-in-docker image."
exit 1
fi
docker version --format 'client {{.Client.Version}} / server {{.Server.Version}}'
# Worth recording: a Lean build wants several GB of RAM, and a silent stall
# in `lake` is almost always memory or disk rather than a real hang.
echo "docker host: $(docker info --format '{{.NCPU}} cpu, {{.MemTotal}} bytes ram, storage {{.Driver}}')"
echo "disk on the docker host:"
docker system df || true
df -h / 2>/dev/null || true
- name: Work out the tags
id: meta
run: |
set -eu
owner="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
remote="${REGISTRY}/${owner}/${IMAGE_NAME}"
ref="${GITHUB_REF_NAME:-main}"
sha="$(echo "${GITHUB_SHA:-unknown}" | cut -c1-12)"
case "${GITHUB_REF:-}" in
refs/tags/*) version="${ref}" ;;
*) version="${ref//\//-}" ;;
esac
# Local tags are what stays on the runner; remote tags are for the registry.
local_tags="${LOCAL_IMAGE}:${version} ${LOCAL_IMAGE}:sha-${sha}"
remote_tags="${remote}:${version} ${remote}:sha-${sha}"
if [ "${ref}" = "main" ]; then
local_tags="${local_tags} ${LOCAL_IMAGE}:latest"
remote_tags="${remote_tags} ${remote}:latest"
fi
{
echo "remote=${remote}"
echo "primary=${LOCAL_IMAGE}:${version}"
echo "local_tags=${local_tags}"
echo "remote_tags=${remote_tags}"
} >> "$GITHUB_OUTPUT"
echo "local: ${local_tags}"
echo "remote: ${remote_tags}"
- name: Build
run: |
set -eu
args=""
for tag in ${{ steps.meta.outputs.local_tags }} ${{ steps.meta.outputs.remote_tags }}; do
args="$args -t $tag"
done
# Reuse the previous build's layers explicitly as well as implicitly, so a
# rebuild does not refetch Mathlib.
cache=""
if [ '${{ inputs.no_cache }}' = 'true' ]; then
cache="--no-cache"
elif docker image inspect "${LOCAL_IMAGE}:latest" >/dev/null 2>&1; then
cache="--cache-from ${LOCAL_IMAGE}:latest"
fi
echo "building with: $cache"
# --progress=plain: the fancy renderer collapses long-running steps into a
# single rewritten line, which makes a stalled build indistinguishable from a
# quiet one in the Actions log.
docker build \
--progress=plain \
-f docker/Dockerfile \
--build-arg "MATHLIB_REV=${{ inputs.mathlib_rev || 'auto' }}" \
--build-arg BUILDKIT_INLINE_CACHE=1 \
$cache $args .
- name: Check the image actually works
run: |
set -eu
image='${{ steps.meta.outputs.primary }}'
docker run --rm "$image" doctor --skip-llm
docker run --rm --entrypoint bash "$image" -lc \
'cd "$LEANBENCH_LEAN_PROJECT" && printf "import Mathlib\ntheorem ci_smoke (n : Nat) : n + 0 = n := by simp\n" > /tmp/CI.lean && lake env lean /tmp/CI.lean && echo "lean + mathlib ok"'
# Whole pipeline, scripted LLM, real compiler. Piped in over stdin so the
# job container needs no bind mount into the host daemon.
docker run --rm -i --entrypoint python3 "$image" - < docker/smoke.py
- name: Log in to the registry
id: login
if: ${{ github.event_name != 'workflow_dispatch' || inputs.push_image == 'true' }}
continue-on-error: true
run: |
set -eu
token='${{ secrets.REGISTRY_TOKEN }}'
[ -n "$token" ] || token='${{ secrets.GITEA_TOKEN }}'
[ -n "$token" ] || token='${{ secrets.GITHUB_TOKEN }}'
user='${{ vars.REGISTRY_USER }}'
[ -n "$user" ] || user='${{ github.actor }}'
if [ -z "$token" ]; then
echo "::warning::No registry token; keeping the image on the runner only."
exit 1
fi
echo "$token" | docker login "${REGISTRY}" -u "$user" --password-stdin
- name: Push
id: push
if: ${{ steps.login.outcome == 'success' }}
continue-on-error: true
run: |
set -eu
for tag in ${{ steps.meta.outputs.remote_tags }}; do
echo "pushing $tag"
docker push "$tag"
done
- name: Leave the environment ready for use
run: |
set -eu
image='${{ steps.meta.outputs.primary }}'
docker volume create "${DATA_VOLUME}" >/dev/null
docker volume create "${BOOKS_VOLUME}" >/dev/null
running=""
if docker ps --format '{{.Names}}' | grep -qx "${SERVICE_CONTAINER}"; then
running="yes"
fi
if [ -n "$running" ] && [ '${{ inputs.refresh_service }}' != 'true' ]; then
echo "${SERVICE_CONTAINER} is already running; leaving it alone so nothing"
echo "in flight is interrupted. Re-run with refresh_service=true to move it"
echo "onto the image just built."
else
docker rm -f "${SERVICE_CONTAINER}" >/dev/null 2>&1 || true
docker run -d \
--name "${SERVICE_CONTAINER}" \
--restart unless-stopped \
-v "${DATA_VOLUME}:/data" \
-v "${BOOKS_VOLUME}:/books" \
-e DASHSCOPE_API_KEY \
--entrypoint sleep \
"$image" infinity
echo "${SERVICE_CONTAINER} is up on $image"
fi
docker exec "${SERVICE_CONTAINER}" lean-bench doctor --skip-llm || true
echo "images kept on this runner:"
docker images "${LOCAL_IMAGE}" --format ' {{.Repository}}:{{.Tag}} {{.Size}} {{.CreatedSince}}'
env:
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
- name: Summary
if: always()
run: |
{
echo "### lean-bench image"
echo ""
echo "Kept on the runner (not cleaned up):"
for tag in ${{ steps.meta.outputs.local_tags }}; do echo "- \`$tag\`"; done
echo ""
if [ '${{ steps.push.outcome }}' = 'success' ]; then
echo "Pushed to the registry:"
for tag in ${{ steps.meta.outputs.remote_tags }}; do echo "- \`$tag\`"; done
else
echo "**Not pushed.** Gitea's container registry rejects the built-in"
echo "Actions token. Create a personal access token with \`write:package\`"
echo "and add it as the \`REGISTRY_TOKEN\` secret (and \`REGISTRY_USER\`"
echo "as a variable if it is not \`${{ github.actor }}\`). The image is on"
echo "the runner either way."
fi
echo ""
echo "Submit a book to the container that is already running:"
echo '```'
echo "docker cp your-book.pdf lean-bench:/books/"
echo "docker exec -e DASHSCOPE_API_KEY=sk-... lean-bench \\"
echo " lean-bench ingest /books/your-book.pdf --pages 30-60"
echo "docker exec lean-bench lean-bench stats"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"