ci: run tests in a prebuilt container image
Python CI / test (3.10) (push) Successful in 1m21s
Python CI / test (3.11) (push) Successful in 1m7s
Python CI / test (3.12) (push) Successful in 1m9s
Python CI / test (3.13) (push) Successful in 1m23s

Matches the convention used by pyPhotoAlbum and the other projects here:
runs-on: linux/amd64 with a container image from the Gitea registry,
instead of setup-python plus an ad-hoc `pip install pytest pytest-cov
flake8 coverage-badge interrogate` on a self-hosted runner.

pyWebLayout is a library, so the image carries all four interpreters
pyproject.toml claims to support - 3.10, 3.11, 3.12, 3.13 - each in its
own venv at /opt/py<version> with every dependency pre-installed. The
matrix picks one per job. A CI run now downloads nothing, and coverage
widens from 3.10/3.12/3.13 to the full declared range.

Ubuntu marks its system Python externally-managed, so per-interpreter
venvs are used rather than --break-system-packages; that also keeps the
four dependency sets isolated.

Two defects in the existing workflow are fixed while rewriting it:

- pytest runs under continue-on-error so the badge steps still execute,
  but nothing afterwards checked its outcome - the job reported green on
  a red suite. An explicit gate now fails the job.
- Every matrix leg ran the badge steps and force-pushed the badges
  branch, so three jobs raced to publish. Badges and artifacts are now
  produced by the 3.13 leg only.

setuptools is pinned below 81 in the image: that release dropped
pkg_resources, which coverage-badge imports at startup, and without the
pin the badge step dies with ModuleNotFoundError. Found by running the
workflow's own commands in the image rather than assuming they work.

Also raises the test Flask server's readiness budget from 5s to 30s.
Making that check raise instead of silently falling through (737cf07)
turned runner load into a hard failure; it showed up as 17 spurious
errors in one containerised run and did not reproduce in three repeats.
The loop still exits as soon as the server answers.

Verified locally: image builds, and tests/ passes 916 on each of 3.10,
3.11, 3.12 and 3.13 inside it. The publishing leg was run end to end -
clean-install dependency check, pytest with coverage, both badges,
coverage summary at 81.5%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 14:10:05 +02:00
co-authored by Claude Opus 5
parent 3761e00398
commit 745fc8687e
5 changed files with 252 additions and 112 deletions
+96 -110
View File
@@ -11,25 +11,47 @@ on:
jobs:
test:
runs-on: self-hosted
runs-on: linux/amd64
container:
# Built from Dockerfile.ci at the repo root. Carries Python 3.10-3.13,
# each in its own venv at /opt/py<version> with every dependency
# pre-installed, so a run downloads nothing.
image: gitea.tourolle.paris/dtourolle/pyweblayout-ci:latest
strategy:
matrix:
python-version: ['3.10', '3.12', '3.13']
python-version: ['3.10', '3.11', '3.12', '3.13']
fail-fast: false
env:
# pyWebLayout is a library: it is tested on every interpreter
# pyproject.toml's requires-python claims to support.
PYBIN: /opt/py${{ matrix.python-version }}/bin
# Badges and artifacts are published once, not once per matrix leg -
# four jobs racing to force-push the same branch is not a publish
# strategy. This leg is the one that publishes.
PUBLISH: ${{ matrix.python-version == '3.13' }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Verify declared dependencies are sufficient
- name: Install project
run: |
# A clean venv with ONLY the declared runtime deps. If an import here
# fails, install_requires is incomplete and a real `pip install
# pyWebLayout` would fail the same way for a user.
python -m venv /tmp/clean-install
# --no-deps: dependencies are baked into the image. If a new one is
# added to pyproject.toml, add it to Dockerfile.ci and rebuild;
# the check below is what catches forgetting to.
$PYBIN/pip install -e . --no-deps
$PYBIN/python -c "import pyWebLayout; print('pyWebLayout', pyWebLayout.__file__)"
- name: Verify declared dependencies are sufficient
if: env.PUBLISH == 'true'
run: |
# A clean venv with ONLY the declared runtime deps, installed from
# the index rather than from the image. If an import here fails,
# pyproject.toml is incomplete and a real `pip install pyWebLayout`
# fails the same way for a user. This is the one step that is
# allowed to reach the network.
$PYBIN/python -m venv /tmp/clean-install
/tmp/clean-install/bin/pip install --upgrade pip
/tmp/clean-install/bin/pip install .
/tmp/clean-install/bin/python -c "
@@ -40,152 +62,116 @@ jobs:
print('clean install imports OK')
"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
# Install package in development mode, with the declared dev extra.
# Test dependencies belong in setup.cfg, not in an ad-hoc pip line.
pip install -e '.[dev]'
- name: Download initial failed badges
run: |
echo "Downloading initial failed badges..."
# Create cov_info directory first
mkdir -p cov_info
# Download failed badges as defaults
curl -o cov_info/coverage.svg "https://img.shields.io/badge/coverage-failed-red.svg"
curl -o cov_info/coverage-docs.svg "https://img.shields.io/badge/docs-failed-red.svg"
echo "Initial failed badges created:"
ls -la cov_info/coverage*.svg
- name: Run tests with pytest
id: pytest
continue-on-error: true
run: |
# Run tests with coverage
python -m pytest tests/ -v --cov=pyWebLayout --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml
$PYBIN/python -m pytest tests/ -v \
--cov=pyWebLayout \
--cov-report=term-missing \
--cov-report=json \
--cov-report=html \
--cov-report=xml
- name: Check documentation coverage
id: docs
continue-on-error: true
run: |
# Generate documentation coverage report
interrogate -v --ignore-init-method --ignore-init-module --ignore-magic --ignore-private --ignore-property-decorators --ignore-semiprivate --fail-under=80 pyWebLayout/
$PYBIN/interrogate -v \
--ignore-init-method --ignore-init-module --ignore-magic \
--ignore-private --ignore-property-decorators --ignore-semiprivate \
--fail-under=80 pyWebLayout/
- name: Lint with flake8
run: |
# Stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
$PYBIN/flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# Exit-zero treats all errors as warnings
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Create coverage info directory
if: always()
$PYBIN/flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Fail the job if tests failed
if: steps.pytest.outcome != 'success'
run: |
# pytest runs with continue-on-error so the badge steps below still
# execute; without this the job would report green on a red suite.
echo "::error::pytest failed on Python ${{ matrix.python-version }}"
exit 1
# ------------------------------------------------------------------
# Badges and artifacts - publishing leg only
# ------------------------------------------------------------------
- name: Prepare badge directory
if: always() && env.PUBLISH == 'true'
run: |
mkdir -p cov_info
echo "Created cov_info directory for coverage data"
# Default to failed badges; the steps below overwrite them on success
curl -o cov_info/coverage.svg "https://img.shields.io/badge/coverage-failed-red.svg"
curl -o cov_info/coverage-docs.svg "https://img.shields.io/badge/docs-failed-red.svg"
- name: Update test coverage badge on success
if: steps.pytest.outcome == 'success' && always()
if: always() && env.PUBLISH == 'true' && steps.pytest.outcome == 'success'
run: |
echo "Tests passed! Generating successful coverage badge..."
if [ -f coverage.json ]; then
coverage-badge -o cov_info/coverage.svg -f
echo "✅ Test coverage badge updated with actual results"
$PYBIN/coverage-badge -o cov_info/coverage.svg -f
echo "✅ Test coverage badge updated"
else
echo "⚠️ No coverage.json found, keeping failed badge"
fi
- name: Update docs coverage badge on success
if: steps.docs.outcome == 'success' && always()
- name: Update docs coverage badge on success
if: always() && env.PUBLISH == 'true' && steps.docs.outcome == 'success'
run: |
echo "Docs check passed! Generating successful docs badge..."
# Remove existing badge first to avoid overwrite error
rm -f cov_info/coverage-docs.svg
interrogate --generate-badge cov_info/coverage-docs.svg pyWebLayout/
echo "✅ Docs coverage badge updated with actual results"
$PYBIN/interrogate --generate-badge cov_info/coverage-docs.svg pyWebLayout/
echo "✅ Docs coverage badge updated"
- name: Generate coverage reports
if: steps.pytest.outcome == 'success'
if: env.PUBLISH == 'true' && steps.pytest.outcome == 'success'
run: |
# Generate coverage summary for README
python -c "
import json
import os
# Read coverage data
$PYBIN/python -c "
import json, os
if os.path.exists('coverage.json'):
with open('coverage.json', 'r') as f:
coverage_data = json.load(f)
total_coverage = round(coverage_data['totals']['percent_covered'], 1)
# Create coverage summary file in cov_info directory
with open('coverage.json') as f:
data = json.load(f)
total = round(data['totals']['percent_covered'], 1)
with open('cov_info/coverage-summary.txt', 'w') as f:
f.write(f'{total_coverage}%')
print(f'Test Coverage: {total_coverage}%')
covered_lines = coverage_data['totals']['covered_lines']
total_lines = coverage_data['totals']['num_statements']
print(f'Lines Covered: {covered_lines}/{total_lines}')
f.write(f'{total}%')
print(f\"Test Coverage: {total}%\")
print(f\"Lines Covered: {data['totals']['covered_lines']}/{data['totals']['num_statements']}\")
else:
print('No coverage data found')
"
# Copy other coverage files to cov_info
if [ -f coverage.json ]; then cp coverage.json cov_info/; fi
if [ -f coverage.xml ]; then cp coverage.xml cov_info/; fi
if [ -d htmlcov ]; then cp -r htmlcov cov_info/; fi
- name: Final badge status
if: always()
if: always() && env.PUBLISH == 'true'
run: |
echo "=== FINAL BADGE STATUS ==="
echo "Test outcome: ${{ steps.pytest.outcome }}"
echo "Docs outcome: ${{ steps.docs.outcome }}"
if [ -f cov_info/coverage.svg ]; then
echo "✅ Test coverage badge: $(ls -lh cov_info/coverage.svg)"
else
echo "❌ Test coverage badge: MISSING"
fi
if [ -f cov_info/coverage-docs.svg ]; then
echo "✅ Docs coverage badge: $(ls -lh cov_info/coverage-docs.svg)"
else
echo "❌ Docs coverage badge: MISSING"
fi
echo "Coverage info directory contents:"
ls -la cov_info/ 2>/dev/null || echo "No cov_info directory found"
ls -la cov_info/ 2>/dev/null || echo "No cov_info directory"
- name: Upload coverage artifacts
if: always() && env.PUBLISH == 'true'
uses: actions/upload-artifact@v3
with:
name: coverage-reports
path: |
cov_info/
path: cov_info/
- name: Commit badges to badges branch
if: github.ref == 'refs/heads/master'
if: env.PUBLISH == 'true' && github.ref == 'refs/heads/master'
run: |
git config --local user.email "action@gitea.local"
git config --local user.name "Gitea Action"
# Set the remote URL to use the token
git remote set-url origin https://${{ secrets.PUSH_TOKEN }}@gitea.tourolle.paris/dtourolle/pyWebLayout.git
# Create a new orphan branch for badges (this discards any existing badges branch)
# Orphan branch holding only the badges, force-pushed each time
git checkout --orphan badges
# Remove all files except cov_info
find . -maxdepth 1 -not -name '.git' -not -name 'cov_info' -exec rm -rf {} + 2>/dev/null || true
# Add only the coverage info directory
git add -f cov_info/
# Always commit (force overwrite)
echo "Force updating badges branch with new coverage data..."
git commit -m "Update coverage badges [skip ci]"
git push -f origin badges