Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Only what the image is built from: no state, caches, secrets (*.env), tests or venv can reach it
*
!ecobot.py
!lib/
!requirements.txt
**/__pycache__
39 changes: 39 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Docker image

on:
push: # any branch, so a pull request's commits carry the check too
paths: [Dockerfile, .dockerignore, requirements.txt, .github/workflows/docker.yml]
workflow_dispatch:

permissions:
contents: read

jobs:
image:
# The image builds from the Dockerfile, the bot starts in it, and the charts it draws there are the approved ones, in Inter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Build
run: docker build --build-arg COMMIT="$GITHUB_SHA" -t ecobot .
- name: The bot starts and names this commit
run: |
docker run --rm --network none -e TELEGRAM_BOT_TOKEN=0:x -e XAI_API_KEY=x ecobot > start.log 2>&1 || true # stops: no data source set
cat start.log
grep -q "Starting ecobot ${GITHUB_SHA::7}" start.log
- name: Charts drawn in the image
run: |
docker run --rm --network none -v "$PWD/tests:/app/tests:ro" -e MPLCONFIGDIR=/tmp/mpl ecobot python -c '
from PIL import Image
from lib import charts
from tests import chart_samples, imagecmp
assert (charts.FONT, charts.TITLE_WEIGHT) == ("Inter", "semibold"), (charts.FONT, charts.TITLE_WEIGHT)
for name, make in chart_samples.SAMPLES.items():
share = imagecmp.difference(imagecmp.snapshot(make()), Image.open(imagecmp.GOLDEN / f"{name}.png"))[0]
assert share <= imagecmp.TOLERANCE, f"{name}: {share:.2%} of the pixels differ from tests/charts/{name}.png"
print("Inter with semibold titles; every sample chart matches its reference image")'
- name: Size
run: |
unpacked=$(docker run --rm --network none --user 0 --entrypoint du ecobot -sx --apparent-size --block-size=1M / | cut -f1)
compressed=$(docker save ecobot | gzip -c | wc -c)
echo "The image: ${unpacked} MB unpacked, $((compressed / 1000000)) MB compressed" | tee -a "$GITHUB_STEP_SUMMARY"
5 changes: 3 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,17 @@ python scripts/eval_prompts.py # the saved questions: decisions made in
```
The code targets Python 3.13.5 (`.python-version`). Claude Code sessions build the same Python into `.venv` with uv (`.claude/hooks/session-start.sh`).

## GitHub Actions (`.github/workflows/ci.yml`)
## GitHub Actions (`.github/workflows/ci.yml`, and `docker.yml` for `image`)
| Job | When | What |
|---|---|---|
| `test` | every push to `main` and every pull request | the exact versions in `requirements.txt`: pytest (chart pictures included) and the offline evals. The gate. |
| `newest` | Sundays 21:17 UTC, or the Run workflow button | the tests with the newest matplotlib, numpy and Pillow. Advisory. |
| `live-evals` | Sundays 21:17 UTC, or the Run workflow button | asks the real model the six questions in `tests/evals/weekly.txt` (`python scripts/eval_prompts.py --live`) and checks which tool it calls; a failure is asked once more before it is reported. Advisory. The button's "all cases" box asks all 87. Skipped until the secrets below exist. |
| `image` | pushes that change `Dockerfile`, `.dockerignore` or `requirements.txt` (any branch), or the Run workflow button | builds the Docker image, checks the bot starts in it and names the commit, draws the sample charts in it (they must be in Inter and match `tests/charts`); the image's size goes in the run's summary. |

Repository secrets (Settings, Secrets and variables, Actions, New repository secret):
- `XAI_API_KEY`: for `live-evals`. `XAI_MODEL` is optional (default `grok-4.3`): add it only if production runs another model.
- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`: optional; failures of any job, and of `test` on `main`, are then sent to Telegram.
- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`: optional; failures of `newest` and `live-evals`, and of `test` on `main`, are then sent to Telegram.

Dependabot (`.github/dependabot.yml`) opens weekly pull requests for the Python packages and the actions. `matplotlib`, `numpy` and `Pillow` come as one pull request, because the chart pictures depend on them.

Expand Down
39 changes: 39 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# The bot as a small image: python:3.13-slim with the runtime packages in a venv that has no pip, no pip cache and no test suites,
# and bytecode for only what the bot imports at start (bytecode for everything is 48 MB more; none adds ~2 s to every start).
# docker build --build-arg COMMIT=$(git rev-parse HEAD) -t ecobot .
# docker run -d --name ecobot --restart unless-stopped --env-file /etc/ecobot.env -v ecobot-data:/data ecobot
# Settings come from the environment, as under systemd (README); the state and caches are kept in /data.
FROM python:3.13-slim AS base

FROM base AS build
# Inter for the charts, in the three weights they use (text, semibold titles, bold labels); the rest of the package stays here
RUN apt-get update \
&& apt-get install -y --no-install-recommends fonts-inter \
&& mkdir /fonts \
&& find /usr/share/fonts -name 'Inter-Regular.*' -o -name 'Inter-SemiBold.*' -o -name 'Inter-Bold.*' | xargs cp -t /fonts
COPY requirements.txt /tmp/
# The base image's pip installs into a venv made without one
RUN python -m venv --without-pip /opt/venv \
&& pip --python /opt/venv/bin/python install --no-cache-dir --no-compile --root-user-action=ignore -r /tmp/requirements.txt \
&& find /opt/venv -type d -name tests -prune -exec rm -rf {} +

FROM base
COPY --from=build /opt/venv /opt/venv
COPY --from=build /fonts/ /usr/share/fonts/inter/
RUN groupadd --system --gid 10001 ecobot \
&& useradd --system --uid 10001 --gid 10001 --no-create-home --home-dir /data ecobot \
&& mkdir /data && chown 10001:10001 /data
WORKDIR /app
COPY ecobot.py ./
COPY lib lib
ENV PATH=/opt/venv/bin:$PATH PYTHONUNBUFFERED=1
# COMMIT goes where the bot reads the running commit from (.git/HEAD) for its start-up log line ("unknown" without it). Importing
# the bot once writes the bytecode of every module it loads at start, and only those.
ARG COMMIT=unknown
LABEL org.opencontainers.image.revision=$COMMIT
RUN mkdir .git && echo "$COMMIT" > .git/HEAD \
&& DATA_DIR=/tmp/build MPLCONFIGDIR=/tmp/build python -c "import lib.bot, ecobot" \
&& rm -rf /tmp/build
ENV DATA_DIR=/data
USER 10001:10001
CMD ["python", "ecobot.py"]
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ Developing, the tests and the GitHub checks: see `DEVELOPMENT.md`.

Under systemd see `ecobot.service` (it loads the environment file; the clone's location is its one `WorkingDirectory` line, the rest is relative).

In Docker, the `Dockerfile` builds a python:3.13-slim image (278 MB unpacked, 98 MB compressed):

```
docker build --build-arg COMMIT=$(git rev-parse HEAD) -t ecobot . # COMMIT: the commit the start-up log names
docker run -d --name ecobot --restart unless-stopped --env-file /etc/ecobot.env -v ecobot-data:/data ecobot
```
The state and caches go in `/data` (its `DATA_DIR`), here a named volume. A host folder instead (`-v /srv/ecobot:/data`) must be writable by the image's user, uid 10001 (`sudo chown 10001:10001 /srv/ecobot`). Docker reads the environment file's `KEY=value` lines as they are: a value in quotes keeps its quotes, where systemd would drop them.

| Variable | Purpose |
|---|---|
| `TELEGRAM_BOT_TOKEN`, `XAI_API_KEY` | required |
Expand Down
Loading