Pipeline CI/CD complet per a Odoo amb GitHub Actions i Docker: guia completa

Arquitectura real d'un pipeline d'integració i desplegament continu per a mòduls Odoo en producció

Per què el CI/CD a Odoo és més difícil del que sembla

Muntar un pipeline de CI/CD per a una aplicació web estàndard és un problema resolt. Per a Odoo, no tant. Odoo té particularitats que trenquen els pipelines genèrics i que la majoria de guies ignoren:

  • L'estat de la base de dades importa: A diferència d'una aplicació sense estat, Odoo manté l'esquema de la base de dades sincronitzat amb el codi font. Actualitzar mòduls sense la migració adequada trenca producció.
  • Els mòduls tenen dependències creuades: Un mòdul custom pot dependre d'un mòdul OCA que al seu torn depèn d'una versió específica d'Odoo. L'ordre d'instal·lació i actualització no és trivial.
  • Els tests necessiten una instància real: Els tests d'Odoo requereixen una base de dades amb l'ERP instal·lat. No pots fer un simple pytest sense una instància funcional amb els mòduls carregats.
  • El deploy no és un reinici: Actualitzar mòduls en producció implica executar -u module_name, que pot trigar minuts en mòduls grans i bloqueja la instància durant aquest temps.

Aquesta guia explica com resoldre cadascun d'aquests problemes amb un pipeline real que faig servir en producció.

Arquitectura del pipeline: visió general

El pipeline que descric aquí té sis fases seqüencials. Cada fase és un job a GitHub Actions. Si qualsevol fase falla, el pipeline s'atura i mai arriba a producció:

  1. Lint i estil de codi — flake8, pylint-odoo, isort
  2. Tests unitaris i integració — pytest-odoo amb base de dades dedicada
  3. Build d'imatge Docker multi-stage — imatge optimitzada i signada
  4. Push a registre privat — GitHub Container Registry o Docker Hub privat
  5. Migració i deploy a staging — actualització de mòduls + smoke tests
  6. Deploy a producció — amb còpia de seguretat prèvia automàtica i health check post-deploy

L'entorn de staging és obligatori. No existeix «desplegar directament a producció des de la branca main». Tot passa per staging primer.

Fase 1: Lint i anàlisi estàtica

El lint és la fase més ràpida i la primera barrera. Un commit que trenca l'estil de codi o té errors estàtics evidents no ha d'arribar ni a executar els tests.

Per a projectes Odoo, el linter específic és pylint-odoo, que coneix les convencions del framework: noms de camps, herència de models, ús correcte de api.depends, api.constrains, etc.

# .github/workflows/ci.yml (fragmento fase lint)
jobs:
  lint:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.10'
          cache: 'pip'

      - name: Install lint dependencies
        run: |
          pip install flake8 pylint-odoo isort

      - name: Run flake8
        run: |
          flake8 --max-line-length=120 \
                 --exclude=__pycache__,.git,migrations \
                 custom_addons/

      - name: Run pylint-odoo
        run: |
          pylint --load-plugins=pylint_odoo \
                 --rcfile=.pylintrc \
                 custom_addons/

      - name: Check import order (isort)
        run: |
          isort --check-only --diff custom_addons/

El fitxer .pylintrc ha de configurar explícitament quins mòduls s'analitzen i deshabilitar comprovacions que no apliquen a Odoo (per exemple, el check d'herència múltiple, que Odoo fa servir extensivament). Un bon punt de partida és el .pylintrc de l'OCA.

Fase 2: Tests amb pytest-odoo

Aquesta és la fase més complexa. Executar els tests d'Odoo en CI requereix aixecar una instància completa amb base de dades PostgreSQL. L'enfocament correcte és fer servir pytest-odoo com a runner, que gestiona la inicialització de l'entorn Odoo de forma compatible amb pytest.

Estructura del servei PostgreSQL a Actions

GitHub Actions permet aixecar serveis auxiliars (contenidors) que estaran disponibles durant l'execució del job. Ho fem servir per a PostgreSQL:

  test:
    runs-on: ubuntu-22.04
    needs: lint

    services:
      postgres:
        image: postgres:15-alpine
        env:
          POSTGRES_USER: odoo_test
          POSTGRES_PASSWORD: odoo_test
          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: Cache Odoo dependencies
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-odoo-deps-${{ hashFiles('requirements.txt') }}

      - name: Install Odoo and dependencies
        run: |
          sudo apt-get update -qq
          sudo apt-get install -y -qq \
            libpq-dev libldap2-dev libsasl2-dev \
            node-less npm wkhtmltopdf
          pip install -r requirements.txt
          pip install pytest pytest-odoo coverage

      - name: Run tests with coverage
        env:
          DB_HOST: localhost
          DB_PORT: 5432
          DB_USER: odoo_test
          DB_PASSWORD: odoo_test
          DB_NAME: odoo_test
        run: |
          python -m pytest \
            --odoo-database=$DB_NAME \
            --odoo-addons-path="odoo/addons,custom_addons" \
            --odoo-config=ci/odoo-test.conf \
            --tb=short \
            --cov=custom_addons \
            --cov-report=xml \
            --cov-fail-under=70 \
            custom_addons/*/tests/

      - name: Upload coverage report
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage.xml

El fitxer odoo-test.conf

La configuració d'Odoo per a l'entorn de test ha de ser minimalista i desactivar funcions que alenteixen els tests (correu, workers, etc.):

[options]
addons_path = odoo/addons,custom_addons
db_host = localhost
db_port = 5432
db_user = odoo_test
db_password = odoo_test
test_enable = True
without_demo = True
log_level = warn
workers = 0
email_from = False
smtp_server = localhost
smtp_port = 1025

Bones pràctiques de tests a Odoo

Els tests a Odoo hereten de TransactionCase o SavepointCase. La diferència és important: TransactionCase fa rollback de tota la transacció al final de cada test (més ràpid), mentre que SavepointCase utilitza savepoints i permet tests més granulars. Per a tests d'integració que necessiten dades persistents entre mètodes, fes servir SavepointCase.

from odoo.tests.common import TransactionCase
from odoo.tests import tagged


@tagged('post_install', '-at_install')
class TestSaleOrderCustom(TransactionCase):

    def setUp(self):
        super().setUp()
        self.partner = self.env['res.partner'].create({
            'name': 'Test Partner CI',
            'email': 'ci@test.com',
        })

    def test_sale_order_confirm_creates_picking(self):
        """Verifica que confirmar un pedido crea el albarán."""
        order = self.env['sale.order'].create({
            'partner_id': self.partner.id,
            'order_line': [(0, 0, {
                'product_id': self.env.ref('product.product_product_1').id,
                'product_uom_qty': 1,
                'price_unit': 100.0,
            })],
        })
        order.action_confirm()
        self.assertEqual(
            order.picking_ids[0].state,
            'confirmed',
            'El albarán debe estar confirmado al confirmar el pedido'
        )

Fase 3: Build d'imatge Docker multi-stage

La imatge Docker ha de ser reproduïble, mínima i traçable. El patró multi-stage permet tenir un stage de build (on instal·lem eines de compilació) i un stage de runtime (on només hi ha el necessari per executar).

# Dockerfile

# =====================
# Stage 1: builder
# =====================
FROM python:3.10-slim-bookworm AS builder

RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    libpq-dev \
    libldap2-dev \
    libsasl2-dev \
    libssl-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /build

COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt

# =====================
# Stage 2: runtime
# =====================
FROM python:3.10-slim-bookworm AS runtime

LABEL org.opencontainers.image.source="https://github.com/REPO"
LABEL org.opencontainers.image.revision="${GIT_SHA}"
LABEL org.opencontainers.image.created="${BUILD_DATE}"

RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    libldap-2.5-0 \
    libsasl2-2 \
    node-less \
    wkhtmltopdf \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Copiar dependencias Python del stage builder
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH

# Usuario no-root para seguridad
RUN groupadd -r odoo && useradd -r -g odoo odoo

WORKDIR /opt/odoo

# Copiar código Odoo y addons custom
COPY --chown=odoo:odoo odoo/ ./odoo/
COPY --chown=odoo:odoo custom_addons/ ./custom_addons/
COPY --chown=odoo:odoo config/ ./config/

USER odoo

EXPOSE 8069 8072

HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
  CMD curl -f http://localhost:8069/web/health || exit 1

ENTRYPOINT ["python", "odoo/odoo-bin"]
CMD ["--config=/opt/odoo/config/odoo.conf"]

El step de build al pipeline etiqueta la imatge amb el SHA del commit i el tag de la branca, la qual cosa garanteix traçabilitat total: sempre pots saber exactament quin codi s'està executant en producció.

  build:
    runs-on: ubuntu-22.04
    needs: test
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}/odoo
          tags: |
            type=sha,prefix=sha-
            type=ref,event=branch
            type=semver,pattern={{version}}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            GIT_SHA=${{ github.sha }}
            BUILD_DATE=${{ github.event.head_commit.timestamp }}

Fases 4 i 5: Deploy a staging amb migració de mòduls

El deploy a staging té dues parts: actualitzar el contenidor i actualitzar els mòduls a la base de dades. Són coses diferents i s'han de fer en ordre.

La migració de mòduls: el pas que ningú documenta bé

Quan un mòdul Odoo té canvis al seu model de dades (nous camps, camps reanomenats, noves vistes), cal executar odoo-bin --update=module_name perquè Odoo apliqui els canvis a l'esquema de la base de dades. Si omets aquest pas, el nou codi intenta accedir a columnes que no existeixen i la instància falla.

L'script de deploy detecta automàticament quins mòduls han canviat comparant el commit anterior amb l'actual:

  deploy-staging:
    runs-on: ubuntu-22.04
    needs: build
    environment: staging

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # necesario para git diff

      - name: Detect changed modules
        id: changed-modules
        run: |
          CHANGED=$(git diff --name-only HEAD~1 HEAD -- custom_addons/ \
            | cut -d'/' -f2 \
            | sort -u \
            | tr '\n' ',')
          CHANGED=${CHANGED%,}  # quitar coma final
          echo "modules=${CHANGED}" >> $GITHUB_OUTPUT
          echo "Changed modules: ${CHANGED}"

      - name: Deploy to staging via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.STAGING_USER }}
          key: ${{ secrets.STAGING_SSH_KEY }}
          script: |
            set -e

            IMAGE=ghcr.io/${{ github.repository }}/odoo:sha-${{ github.sha }}
            MODULES="${{ steps.changed-modules.outputs.modules }}"

            echo "[1/4] Pulling new image: ${IMAGE}"
            docker pull ${IMAGE}

            echo "[2/4] Stopping current Odoo workers (keep DB up)"
            docker compose -f /opt/odoo/docker-compose.staging.yml stop odoo

            echo "[3/4] Running module migrations: ${MODULES}"
            if [ -n "${MODULES}" ]; then
              docker run --rm \
                --network odoo_network \
                --env-file /opt/odoo/staging.env \
                ${IMAGE} \
                --config=/opt/odoo/config/odoo.conf \
                --database=odoo_staging \
                --update=${MODULES} \
                --stop-after-init
            fi

            echo "[4/4] Starting Odoo with new image"
            IMAGE=${IMAGE} docker compose \
              -f /opt/odoo/docker-compose.staging.yml \
              up -d --no-deps odoo

      - name: Smoke tests on staging
        run: |
          sleep 30  # esperar arranque
          # Health check endpoint nativo de Odoo 16+
          STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
            https://staging.skanndar.top/web/health)
          if [ "$STATUS" != "200" ]; then
            echo "Health check failed: HTTP $STATUS"
            exit 1
          fi
          echo "Staging health check OK (HTTP $STATUS)"

Fase 6: Deploy a producció amb còpia de seguretat prèvia

El deploy a producció només s'executa quan el deploy a staging ha estat exitós I hi ha una aprovació manual a GitHub Environments. Aquesta barrera manual és deliberada: en producció, un humà ha de confirmar que vol procedir.

El pas més crític del deploy a producció és la còpia de seguretat prèvia. Una còpia de seguretat que no s'ha executat correctament abans del deploy no és una còpia de seguretat: és una il·lusió de seguretat.

  deploy-production:
    runs-on: ubuntu-22.04
    needs: deploy-staging
    environment: production  # requiere aprobación manual en GitHub

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - name: Detect changed modules
        id: changed-modules
        run: |
          CHANGED=$(git diff --name-only HEAD~1 HEAD -- custom_addons/ \
            | cut -d'/' -f2 \
            | sort -u \
            | tr '\n' ',')
          echo "modules=${CHANGED%,}" >> $GITHUB_OUTPUT

      - name: Deploy to production via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            set -e

            IMAGE=ghcr.io/${{ github.repository }}/odoo:sha-${{ github.sha }}
            MODULES="${{ steps.changed-modules.outputs.modules }}"
            TIMESTAMP=$(date +%Y%m%d_%H%M%S)
            BACKUP_PATH=/opt/backups/pre-deploy-${TIMESTAMP}.dump

            echo "[1/5] Pre-deploy backup"
            docker exec postgres16 pg_dump \
              -U odoo \
              -Fc \
              odoo_production \
              -f ${BACKUP_PATH}

            # Verificar que el backup es válido (size > 1MB)
            BACKUP_SIZE=$(stat -c%s ${BACKUP_PATH})
            if [ ${BACKUP_SIZE} -lt 1048576 ]; then
              echo "ERROR: Backup demasiado pequeño (${BACKUP_SIZE} bytes). Abortando deploy."
              exit 1
            fi
            echo "Backup OK: ${BACKUP_PATH} (${BACKUP_SIZE} bytes)"

            echo "[2/5] Pulling new image"
            docker pull ${IMAGE}

            echo "[3/5] Stopping Odoo workers"
            docker compose -f /opt/odoo/docker-compose.prod.yml stop odoo

            echo "[4/5] Running module migrations on production"
            if [ -n "${MODULES}" ]; then
              docker run --rm \
                --network odoo_network \
                --env-file /opt/odoo/production.env \
                ${IMAGE} \
                --config=/opt/odoo/config/odoo.prod.conf \
                --database=odoo_production \
                --update=${MODULES} \
                --stop-after-init
            fi

            echo "[5/5] Starting new Odoo"
            IMAGE=${IMAGE} docker compose \
              -f /opt/odoo/docker-compose.prod.yml \
              up -d --no-deps odoo

      - name: Production health check
        run: |
          sleep 45
          for i in 1 2 3 4 5; do
            STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
              https://skanndar.top/web/health 2>/dev/null || echo "000")
            if [ "$STATUS" = "200" ]; then
              echo "Production health check OK (intento $i)"
              exit 0
            fi
            echo "Intento $i: HTTP $STATUS. Esperando..."
            sleep 15
          done
          echo "ERROR: Production health check failed tras 5 intentos"
          exit 1

      - name: Notify deploy result
        if: always()
        uses: appleboy/telegram-action@v0.1.1
        with:
          to: ${{ secrets.TELEGRAM_CHAT_ID }}
          token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
          message: |
            ${{ job.status == 'success' && '✅' || '❌' }} Deploy producción Odoo
            Commit: ${{ github.sha }}
            Módulos: ${{ steps.changed-modules.outputs.modules }}
            Estado: ${{ job.status }}

Bones pràctiques addicionals

Dades anonimitzades a staging

Staging ha de fer servir una còpia de la base de dades de producció, però amb dades personals anonimitzades. Mai facis servir dades reals de clients, emails reals o dades financeres reals a staging. El mòdul odoo_data_anonymization de l'OCA o un script d'anonimització custom abans de restaurar el dump són les dues opcions habituals.

El procés de refresc de staging ha d'estar automatitzat i executar-se almenys setmanalment, perquè staging sigui un mirall fidel de producció sense dades personals:

#!/bin/bash
# scripts/refresh-staging.sh
set -e

TIMESTAMP=$(date +%Y%m%d)
DUMP=/tmp/prod-anonymized-${TIMESTAMP}.dump

echo "Dumping production database..."
docker exec postgres16 pg_dump -U odoo -Fc odoo_production -f ${DUMP}

echo "Anonymizing data..."
docker run --rm \
  -v ${DUMP}:/dump.dump \
  -e PGPASSWORD=odoo \
  odoo-anonymizer:latest \
  --input=/dump.dump \
  --output=/dump-anon.dump \
  --rules=/etc/anonymizer-rules.yml

echo "Restoring to staging..."
docker exec postgres16-staging dropdb --if-exists odoo_staging
docker exec postgres16-staging createdb odoo_staging
docker exec postgres16-staging pg_restore \
  -U odoo \
  -d odoo_staging \
  /dump-anon.dump

echo "Staging refreshed at ${TIMESTAMP}"

Gestió de secrets

Mai no codifiquis credencials al repositori. Fes servir sempre GitHub Encrypted Secrets per a les variables sensibles del pipeline (DB_PASSWORD, SSH_KEY, TELEGRAM_BOT_TOKEN, etc.). Per a les variables d'entorn d'Odoo als servidors, fes servir fitxers .env fora del repositori, muntats als contenidors via env_file a docker-compose.

El fitxer odoo.conf que va al repositori mai ha de contenir contrasenyes reals. Les variables d'entorn injectades al contenidor sobreescriuen les del fitxer de configuració, que és el mecanisme correcte.

Control de versions del workflow

Fixa sempre les versions de les GitHub Actions que fas servir (actions/checkout@v4, no actions/checkout@main). Una Action de tercers pot actualitzar la seva branca main amb codi maliciós. Fer servir tags específics o SHAs complets és la pràctica de seguretat correcta en supply chain.

Paral·lelització de tests

Si el temps d'execució dels tests creix (cosa esperada en projectes madurs), pots paral·lelitzar fent servir l'estratègia matrix de GitHub Actions per distribuir els mòduls entre diversos jobs, o fent servir pytest-xdist per paral·lelitzar tests dins del mateix job. Amb 50+ tests, la diferència entre execució seqüencial i paral·lela pot ser de 10 vs 3 minuts.

Rollback ràpid

El rollback a Odoo és més complex que en una aplicació sense estat. Si la nova versió del codi inclou migracions de base de dades (noves columnes, columnes reanomenades), no pots simplement tornar al contenidor anterior: la base de dades ja ha estat migrada i el codi antic no l'entén.

Per això la còpia de seguretat pre-deploy és obligatòria. El procediment complet de rollback és:

  1. Aturar el contenidor d'Odoo.
  2. Restaurar la còpia de seguretat de la base de dades feta abans del deploy.
  3. Tornar a llançar el contenidor amb la imatge anterior (que és al registre amb el seu tag de SHA).

Aquest procés ha d'estar documentat i provat periòdicament. Un rollback que mai s'ha provat és un rollback que fallarà quan més es necessiti.

El pipeline complet: resum de temps

Un pipeline ben configurat per a un projecte Odoo de mida mitjana (10-20 mòduls custom, 200-500 tests) té aquests temps aproximats:

Fase Temps típic Paral·lelitzable
Lint (flake8 + pylint-odoo) 1-2 min Amb test (en paral·lel)
Tests (pytest-odoo) 5-15 min Amb lint (en paral·lel)
Build imatge Docker 3-8 min (amb caché: <2 min) No (espera tests)
Deploy staging + migració 3-6 min No (espera build)
Smoke tests staging 1-2 min No (espera deploy staging)
Aprovació manual producció Variable (humà)
Backup + deploy + migració prod 5-10 min No
Health check producció 1-2 min No

Temps total des del push fins a producció: 20-45 minuts, amb la major part dedicada a tests i a l'aprovació humana. Sense CI/CD, el mateix procés manual d'un desenvolupador experimentat triga entre 45 minuts i 2 hores, i amb molt més marge d'error.

Conclusió

Un pipeline CI/CD per a Odoo ben construït elimina la principal font de caigudes en producció: l'error humà en el desplegament manual. Cada vegada que el pipeline detecta una fallada abans d'arribar a producció —sigui un test trencat, un lint fallit, o una migració que no arrenca— estàs evitant una incidència real. En projectes on una hora de caiguda costa milers d'euros en operativa perduda, la inversió en automatització s'amortitza en el primer incident que no ocorre.

El workflow que he descrit aquí no és teòric: és el mateix esquema que faig servir en els meus projectes en producció, adaptat i simplificat per a aquesta guia. Els detalls concrets (noms de serveis, rutes, variables d'entorn) varien per projecte, però l'estructura de sis fases, la validació de còpia de seguretat, i el health check post-deploy són invariables.

Vols implementar CI/CD al teu projecte Odoo?

Sol·licitar auditoria tècnica gratuïta

Odoo Alta Disponibilitat amb Patroni i HAProxy: arquitectura HA
Com dissenyar una arquitectura HA real per a Odoo amb Patroni, replicació PostgreSQL streaming, HAProxy i filestore compartit: RTO, RPO i proves de failover inclosos.