From 2740c05218e21dd45fb206a3dfede7d36dd16f04 Mon Sep 17 00:00:00 2001 From: Macaron27 Date: Sun, 27 Sep 2026 14:15:50 +0200 Subject: [PATCH] Add GitHub CI on native amd64 + arm64; move to Python 3.14 CI (.github/workflows/ci.yml), on push to main, PRs and manual runs: - test (amd64, arm64): hash-locked install, ruff format/lint, mypy --strict, pytest with a 90% branch-coverage floor - docker (amd64, arm64): native buildx build with GHA layer cache, smoke run hardened like compose (read-only, cap-drop ALL, no-new-privileges; must exit 2 with "config error"), non-root UID check, compose file validation - least-privilege token (contents: read), SHA-pinned actions, no persisted checkout credentials, per-ref concurrency, job timeouts Uses GitHub's free arm64 runners for public repos (ubuntu-24.04-arm), so arm64 is built and tested natively instead of under QEMU. Python 3.14 everywhere so what is tested is what ships: image pinned to python:3.14.7-slim-trixie, lock regenerated for 3.14 (all 17 Linux wheels verified for cp314 on both arches), ruff/mypy targets bumped. ruff's py314 formatter applies PEP 758 unparenthesized except in the webhook handler. Dependabot: weekly grouped action SHA bumps and Python patch-only base image bumps. pip stays manual (hash-locked uv output). Co-Authored-By: Claude Opus 5.5 --- .claude/known_errors.md | 4 ++ .github/dependabot.yml | 21 +++++++++ .github/workflows/ci.yml | 95 ++++++++++++++++++++++++++++++++++++++++ Dockerfile | 4 +- README.md | 8 ++-- bot/webhooks/server.py | 2 +- pyproject.toml | 15 +++++-- requirements.txt | 10 +---- 8 files changed, 141 insertions(+), 18 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml diff --git a/.claude/known_errors.md b/.claude/known_errors.md index 24d24eb..d77f461 100644 --- a/.claude/known_errors.md +++ b/.claude/known_errors.md @@ -20,3 +20,7 @@ ## `pip download --python-version` evaluated markers against the host interpreter (2026-09-27) - **Error:** checking Linux cp312 wheels from a 3.14 venv tried to fetch `audioop-lts; python_version >= "3.13"`. - **Fix:** run `pip download --platform ... --python-version 3.12` from a real 3.12 interpreter (`uv python install 3.12`), or resolve with `uv pip compile --python-version`. + +## Fetched-page summary contradicted the source (2026-09-27) +- **Error:** a WebFetch summary of GitHub's runner docs said arm64 Linux runners are private-repo only. The docs source (`github/docs` `data/reusables/actions/supported-github-runners.md`) lists `ubuntu-24.04-arm` under "Standard ... runners for public repositories", which are free. +- **Fix:** before a summarized page drives a design decision, check the raw source (repo markdown, `action.yml` at the pinned SHA, package metadata). diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..67e5f0c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] + + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + ignore: + # Patch bumps only; a new Python minor (3.15) is a deliberate migration. + - dependency-name: python + update-types: ["version-update:semver-minor", "version-update:semver-major"] + +# pip is intentionally absent: requirements.txt is a hash-locked `uv pip compile` output. +# Regenerate it by hand (README > Develop and test) when bumping requirements.in. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5f74a58 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# Actions are pinned to full commit SHAs (tag in the comment); Dependabot keeps them current. +jobs: + test: + name: test (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - { arch: amd64, runner: ubuntu-24.04 } + - { arch: arm64, runner: ubuntu-24.04-arm } + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: | + requirements.txt + pyproject.toml + - name: Install hash-locked runtime deps + dev group + run: | + python -m pip install --require-hashes --only-binary=:all: -r requirements.txt + python -m pip install --group dev + - name: Format + run: ruff format --check bot tests + - name: Lint + run: ruff check --output-format=github bot tests + - name: Types (mypy --strict) + run: mypy + - name: Tests + coverage floor + run: python -m pytest -q --cov --cov-report=term-missing + + docker: + name: docker (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - { arch: amd64, platform: linux/amd64, runner: ubuntu-24.04 } + - { arch: arm64, platform: linux/arm64, runner: ubuntu-24.04-arm } + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - name: Build image (native ${{ matrix.platform }}) + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + platforms: ${{ matrix.platform }} + load: true + push: false + tags: dockerdiscord:ci + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + - name: Smoke test, hardened like docker-compose.yml + # With no config the entrypoint must fail fast: exit 2 + "config error". + run: | + set +e + out=$(docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges:true dockerdiscord:ci 2>&1) + code=$? + set -e + echo "$out" + [ "$code" -eq 2 ] || { echo "::error::expected exit code 2, got $code"; exit 1; } + grep -q "config error" <<<"$out" || { echo "::error::missing 'config error' message"; exit 1; } + - name: Image runs as non-root UID 10001 + run: | + uid=$(docker run --rm --entrypoint python dockerdiscord:ci -c 'import os; print(os.getuid())') + [ "$uid" = "10001" ] || { echo "::error::container runs as UID $uid"; exit 1; } + - name: Validate docker-compose.yml + if: matrix.arch == 'amd64' + run: | + cp .env.example .env + DOCKER_GID=999 docker compose config --quiet diff --git a/Dockerfile b/Dockerfile index 22a9be6..cad0591 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 # Pinned to the exact interpreter the test suite runs on; bump deliberately. -FROM python:3.12.14-slim-trixie AS build +FROM python:3.14.7-slim-trixie AS build ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1 RUN python -m venv /opt/venv COPY requirements.txt . @@ -9,7 +9,7 @@ COPY requirements.txt . RUN /opt/venv/bin/pip install --require-hashes --only-binary=:all: -r requirements.txt \ && /opt/venv/bin/pip uninstall --yes pip -FROM python:3.12.14-slim-trixie +FROM python:3.14.7-slim-trixie ENV PATH=/opt/venv/bin:$PATH PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 COPY --from=build /opt/venv /opt/venv WORKDIR /app diff --git a/README.md b/README.md index 27c0476..5cf90c8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # DockerDiscord +[![CI](https://github.com/Macaron27/DockerDiscord/actions/workflows/ci.yml/badge.svg)](https://github.com/Macaron27/DockerDiscord/actions/workflows/ci.yml) + A Discord bot that controls a homelab with slash commands and posts incident alerts with remediation buttons. | Command | What it does | @@ -77,15 +79,15 @@ python3 -m venv .venv && .venv/bin/pip install --require-hashes -r requirements. ``` ```bash -.venv/bin/python -m pytest -q --cov=bot --cov-branch && .venv/bin/ruff check bot tests && .venv/bin/mypy +.venv/bin/python -m pytest -q --cov && .venv/bin/ruff format --check bot tests && .venv/bin/ruff check bot tests && .venv/bin/mypy ``` -The tests fake Discord and Docker, so they need no network. That includes `tests/test_proxy_allowlist.py`, which runs the proxy regexes against the URLs docker-py really sends. +CI (`.github/workflows/ci.yml`) runs the same checks on native amd64 and arm64 runners, builds the image for both, and smoke-tests it hardened like the compose file. Coverage below 90% fails the build. The tests fake Discord and Docker, so they need no network. That includes `tests/test_proxy_allowlist.py`, which runs the proxy regexes against the URLs docker-py really sends. **Dependencies:** `requirements.in` lists the direct pins. `requirements.txt` is the hash-locked lock for every platform, and the Dockerfile installs it wheels-only with `--require-hashes`. After changing `requirements.in`, regenerate it: ```bash -.venv/bin/uv pip compile requirements.in --universal --python-version 3.12 --generate-hashes -o requirements.txt +.venv/bin/uv pip compile requirements.in --universal --python-version 3.14 --generate-hashes -o requirements.txt ``` ## First-run smoke test (on the real host) diff --git a/bot/webhooks/server.py b/bot/webhooks/server.py index a344e47..b7b658e 100644 --- a/bot/webhooks/server.py +++ b/bot/webhooks/server.py @@ -79,7 +79,7 @@ async def alert(request: web.Request) -> web.Response: ) try: payload = await request.json() # bodies over MAX_BODY are rejected with 413 by aiohttp - except (json.JSONDecodeError, UnicodeDecodeError): + except json.JSONDecodeError, UnicodeDecodeError: return web.json_response({"error": "body must be JSON"}, status=400) incidents = parse_payload(payload) if isinstance(payload, dict) else None if incidents is None: diff --git a/pyproject.toml b/pyproject.toml index a1560c4..f054a33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "dockerdiscord" version = "1.0.0" -requires-python = ">=3.12" +requires-python = ">=3.14" license = "MIT" # Runtime deps: edit requirements.in, then regenerate the hash-locked requirements.txt (see README). @@ -24,12 +24,11 @@ faulthandler_timeout = 10 faulthandler_exit_on_timeout = true filterwarnings = [ "error::DeprecationWarning:bot", # our own code must not rely on deprecated APIs - "ignore:'audioop' is deprecated:DeprecationWarning:discord.player", # discord.py voice, unused ] [tool.ruff] line-length = 110 -target-version = "py312" +target-version = "py314" [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "UP", "S", "ASYNC", "RUF"] @@ -39,7 +38,15 @@ ignore = ["S101", "S104", "RUF001"] # asserts for narrowing; 0.0.0.0 bind is in "tests/*" = ["S105", "S106"] [tool.mypy] -python_version = "3.12" +python_version = "3.14" strict = true warn_unreachable = true files = ["bot"] + +[tool.coverage.run] +branch = true +source = ["bot"] + +[tool.coverage.report] +fail_under = 90 +show_missing = true diff --git a/requirements.txt b/requirements.txt index 2c4f948..7b70333 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile requirements.in --universal --python-version 3.12 --generate-hashes -o requirements.txt +# uv pip compile requirements.in --universal --python-version 3.14 --generate-hashes -o requirements.txt aiohappyeyeballs==2.7.1 \ --hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \ --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 @@ -133,7 +133,7 @@ attrs==26.1.0 \ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 # via aiohttp -audioop-lts==0.2.2 ; python_full_version >= '3.13' \ +audioop-lts==0.2.2 \ --hash=sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a \ --hash=sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449 \ --hash=sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3 \ @@ -877,12 +877,6 @@ requests==2.34.2 \ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed # via docker -typing-extensions==4.16.0 ; python_full_version < '3.13' \ - --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ - --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 - # via - # aiohttp - # aiosignal urllib3==2.8.0 \ --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \ --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63