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
4 changes: 4 additions & 0 deletions .claude/known_errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
21 changes: 21 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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.
95 changes: 95 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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 .
Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 |
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion bot/webhooks/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 11 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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).

Expand All @@ -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"]
Expand All @@ -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
10 changes: 2 additions & 8 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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
Expand Down