Custom Odoo module repositories almost universally lack CI. The common justification is that running a full Odoo test suite requires a running Odoo server, a PostgreSQL database, and ten minutes of container startup time - making it too slow and too complex to set up.
This post presents a practical GitHub Actions pipeline that runs in under three minutes and catches the most common regression classes without the full Odoo server overhead.
What the pipeline checks#
The pipeline covers four layers:
- Python syntax and lint - catches import errors, undefined names, obvious type mismatches
- XML validity - catches malformed view files before they hit Odoo
- Migration file presence - ensures
.pymigration scripts exist when a schema change is detected - Odoo module test runner - runs
odoo-bin -utests in a Docker container with a real PostgreSQL database
The first three run in 20–30 seconds on any Python image, without Odoo. The fourth runs in 2–4 minutes in a Docker Compose environment.
Repository structure assumptions#
This pipeline assumes your repository looks like this:
my_custom_modules/
module_a/
__manifest__.py
models/
views/
tests/
migrations/
module_b/
...
.github/
workflows/
ci.yml
docker-compose.ci.yml
requirements.txtMultiple modules, one repo. If you have one module per repo, the logic is the same - skip the loop over module directories.
The workflow file#
# .github/workflows/ci.yml
name: Odoo Module CI
on:
push:
branches: [main, staging]
pull_request:
branches: [main, staging]
env:
ODOO_VERSION: "18.0"
DB_NAME: odoo_test
jobs:
lint:
name: Lint and validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Python lint tools
run: pip install flake8 pylint
- name: Flake8 - syntax and style
run: |
flake8 . --max-line-length=120 --extend-ignore=E501,W503 --exclude=.git,__pycache__,migrations
- name: Validate XML
run: |
python - <<'PYEOF'
import os, sys
from xml.etree import ElementTree
errors = []
for root_dir, dirs, files in os.walk("."):
dirs[:] = [d for d in dirs if d not in {".git", "__pycache__"}]
for f in files:
if f.endswith(".xml"):
path = os.path.join(root_dir, f)
try:
ElementTree.parse(path)
except ElementTree.ParseError as e:
errors.append(f"{path}: {e}")
if errors:
for e in errors:
print(e, file=sys.stderr)
sys.exit(1)
print(f"All XML files valid.")
PYEOF
- name: Check migration files for schema changes
run: |
python - <<'PYEOF'
import subprocess, sys, re, os
# Find Python files changed in this PR
result = subprocess.run(
["git", "diff", "--name-only", "origin/main...HEAD"],
capture_output=True, text=True
)
changed = result.stdout.splitlines()
# Flag models.py changes that add/alter Column definitions
schema_modules = set()
for path in changed:
if "/models/" in path and path.endswith(".py"):
diff = subprocess.run(
["git", "diff", "origin/main...HEAD", "--", path],
capture_output=True, text=True
).stdout
if re.search(r"^+.*fields.(Char|Integer|Float|Many2one|One2many|Boolean|Date|Datetime|Binary|Selection|Text)(", diff, re.MULTILINE):
module = path.split("/")[0]
schema_modules.add(module)
missing = []
for module in schema_modules:
migrations_dir = os.path.join(module, "migrations")
if not os.path.isdir(migrations_dir):
missing.append(module)
else:
migration_files = [
f for f in os.listdir(migrations_dir)
if f.endswith(".py") and f != "__init__.py"
]
if not migration_files:
missing.append(module)
if missing:
print(f"Schema changes detected in {missing} but no migration files found.", file=sys.stderr)
sys.exit(1)
print("Migration check passed.")
PYEOF
test:
name: Odoo module tests
runs-on: ubuntu-latest
needs: lint
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo
POSTGRES_DB: odoo_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Pull Odoo image
run: docker pull odoo:18.0
- name: Init Odoo database
run: |
docker run --rm --network host -e DB_HOST=localhost -e DB_PORT=5432 -e DB_USER=odoo -e DB_PASSWORD=odoo -v ${{ github.workspace }}:/mnt/extra-addons odoo:18.0 odoo --init base --database odoo_test --stop-after-init --no-http
- name: Run module tests
run: |
MODULES=$(ls -d */ | grep -v '^.' | sed 's|/||' | tr '
' ',')
docker run --rm --network host -e DB_HOST=localhost -e DB_PORT=5432 -e DB_USER=odoo -e DB_PASSWORD=odoo -v ${{ github.workspace }}:/mnt/extra-addons odoo:18.0 odoo --update "$MODULES" --database odoo_test --stop-after-init --test-enable --log-level=test --no-httpThe tradeoffs#
What this pipeline catches:
- Syntax errors (Python) - immediately on push
- Malformed XML that would crash Odoo on startup
- Missing migration files when fields are added (catches the most common upgrade bug)
- Test failures in your
tests/directories
What it does not catch:
- Performance regressions - the test runner does not measure query counts or execution time
- Security rule gaps - the test suite only exercises paths your tests cover
- UI regressions - there is no browser in this pipeline
For UI testing, look at Playwright with Odoo's built-in runbot or a dedicated staging environment. That is a separate pipeline that runs less frequently.
Making it faster#
The slow part is the docker pull odoo:18.0 and database init. Two optimizations that cut the CI time significantly:
Cache the Odoo image. GitHub Actions has a Docker layer cache action. Add this before your pull step:
- name: Cache Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: odoo-18-${{ hashFiles('requirements.txt') }}Use a pre-initialized database artifact. Run the database init in a separate scheduled workflow (weekly), save the PostgreSQL data directory as a tarball artifact, and restore it in your test jobs. This removes the 60–90 second init step from every PR run.
Adding test coverage enforcement#
Once the basic pipeline is running, add a coverage step:
- name: Coverage report
run: |
docker run --rm --network host -v ${{ github.workspace }}:/mnt/extra-addons odoo:18.0 odoo --update "$MODULES" --database odoo_test --stop-after-init --test-enable --coverage
# Fail if coverage < 60%
docker run --rm -v ${{ github.workspace }}:/mnt/extra-addons odoo:18.0 python -m coverage report --fail-under=60Start at 60% and raise the threshold as you add tests. Do not try to enforce 100% from day one - it causes developers to write coverage-gaming tests instead of useful ones.
The single highest-value thing in this pipeline#
It is the migration file check, not the test runner. Most Odoo upgrade regressions happen because a developer added a field, did not write a migration, and the field silently does not appear on existing records after upgrade. The test suite misses it because the test creates fresh records. The migration check catches it at push time.
If you only implement one step from this pipeline, implement that one.

