All posts
Developer10 min read

CI/CD for custom Odoo modules with GitHub Actions

A working GitHub Actions pipeline that lints, tests, and validates migrations for custom Odoo modules - without spinning up a full Odoo server on every push.

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:

  1. Python syntax and lint - catches import errors, undefined names, obvious type mismatches
  2. XML validity - catches malformed view files before they hit Odoo
  3. Migration file presence - ensures .py migration scripts exist when a schema change is detected
  4. Odoo module test runner - runs odoo-bin -u tests 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.txt

Multiple modules, one repo. If you have one module per repo, the logic is the same - skip the loop over module directories.

The workflow file#

yaml
# .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-http

The 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:

yaml
- 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:

yaml
- 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=60

Start 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.

Try ERPeek on your own Odoo module - ask questions, scaffold tests, and explore your codebase in plain language.

Get started free