Por qué el CI/CD en Odoo es más difícil de lo que parece
Montar un pipeline de CI/CD para una aplicación web estándar es un problema resuelto. Para Odoo, no tanto. Odoo tiene particularidades que rompen los pipelines genéricos y que la mayoría de guías ignoran:
- El estado de la base de datos importa: A diferencia de una aplicación stateless, Odoo mantiene el esquema de la base de datos sincronizado con el código fuente. Actualizar módulos sin la migración adecuada rompe producción.
- Los módulos tienen dependencias cruzadas: Un módulo custom puede depender de un módulo OCA que a su vez depende de una versión específica de Odoo. El orden de instalación y actualización no es trivial.
- Los tests necesitan una instancia real: Los tests de Odoo requieren una base de datos con el ERP instalado. No puedes hacer un simple
pytestsin una instancia funcional con los módulos cargados. - El deploy no es un reinicio: Actualizar módulos en producción implica ejecutar
-u module_name, que puede tardar minutos en módulos grandes y bloquea la instancia durante ese tiempo.
Esta guía explica cómo resolver cada uno de estos problemas con un pipeline real que uso en producción.
Arquitectura del pipeline: visión general
El pipeline que describo aquí tiene seis fases secuenciales. Cada fase es un job en GitHub Actions. Si cualquier fase falla, el pipeline se detiene y nunca llega a producción:
- Lint y estilo de código — flake8, pylint-odoo, isort
- Tests unitarios e integración — pytest-odoo con base de datos dedicada
- Build de imagen Docker multi-stage — imagen optimizada y firmada
- Push a registro privado — GitHub Container Registry o Docker Hub privado
- Migración y deploy en staging — actualización de módulos + smoke tests
- Deploy a producción — con backup previo automático y health check post-deploy
El entorno de staging es obligatorio. No existe «desplegar directamente a producción desde la rama main». Todo pasa por staging primero.
Fase 1: Lint y análisis estático
El lint es la fase más rápida y la primera barrera. Un commit que rompe el estilo de código o tiene errores estáticos evidentes no debe ni llegar a ejecutar los tests.
Para proyectos Odoo, el linter específico es pylint-odoo, que conoce las convenciones del framework: nombres de campos, herencia de modelos, uso correcto 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 archivo .pylintrc debe configurar explícitamente qué módulos se analizan y deshabilitar checks que no aplican a Odoo (por ejemplo, el check de herencia múltiple, que Odoo usa extensivamente). Un buen punto de partida es el .pylintrc de la OCA.
Fase 2: Tests con pytest-odoo
Esta es la fase más compleja. Ejecutar los tests de Odoo en CI requiere levantar una instancia completa con base de datos PostgreSQL. El approach correcto es usar pytest-odoo como runner, que gestiona la inicialización del entorno Odoo de forma compatible con pytest.
Estructura del servicio PostgreSQL en Actions
GitHub Actions permite levantar servicios auxiliares (containers) que estarán disponibles durante la ejecución del job. Usamos esto para 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 fichero odoo-test.conf
La configuración de Odoo para el entorno de test debe ser minimalista y desactivar funciones que ralentizan los tests (correo, 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
Buenas prácticas de tests en Odoo
Los tests en Odoo heredan de TransactionCase o SavepointCase. La diferencia es importante: TransactionCase hace rollback de toda la transacción al final de cada test (más rápido), mientras que SavepointCase usa savepoints y permite tests más granulares. Para tests de integración que necesitan datos persistentes entre métodos, usa 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 de imagen Docker multi-stage
La imagen Docker debe ser reproducible, mínima y trazable. El patrón multi-stage permite tener un stage de build (donde instalamos herramientas de compilación) y un stage de runtime (donde solo hay lo necesario para ejecutar).
# 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 en el pipeline etiqueta la imagen con el SHA del commit y el tag de la rama, lo que garantiza trazabilidad total: siempre puedes saber exactamente qué código está corriendo en producción.
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 }}
Fase 4 y 5: Deploy en staging con migración de módulos
El deploy en staging tiene dos partes: actualizar el contenedor y actualizar los módulos en la base de datos. Son cosas distintas y deben hacerse en orden.
La migración de módulos: el paso que nadie documenta bien
Cuando un módulo Odoo tiene cambios en su modelo de datos (nuevos campos, campos renombrados, nuevas vistas), hay que ejecutar odoo-bin --update=module_name para que Odoo aplique los cambios al esquema de la base de datos. Si omites este paso, el código nuevo intenta acceder a columnas que no existen y la instancia falla.
El script de deploy detecta automáticamente qué módulos han cambiado comparando el commit anterior con el 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ón con backup previo
El deploy a producción solo se ejecuta cuando el deploy en staging ha sido exitoso Y hay una aprobación manual en GitHub Environments. Esta barrera manual es deliberada: en producción, un humano debe confirmar que quiere proceder.
El paso más crítico del deploy a producción es el backup previo. Un backup que no se ha ejecutado correctamente antes del deploy no es un backup: es una ilusión de seguridad.
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 }}
Buenas prácticas adicionales
Datos anonimizados en staging
Staging debe usar una copia de la base de datos de producción, pero con datos personales anonimizados. Nunca uses datos reales de clientes, emails reales o datos financieros reales en staging. El módulo odoo_data_anonymization de la OCA o un script de anonimización custom antes de restaurar el dump son las dos opciones habituales.
El proceso de refresco de staging debe estar automatizado y ejecutarse al menos semanalmente, para que staging sea un espejo fiel de producción sin datos personales:
#!/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ón de secretos
Nunca hardcodees credenciales en el repositorio. Usa siempre GitHub Encrypted Secrets para las variables sensibles del pipeline (DB_PASSWORD, SSH_KEY, TELEGRAM_BOT_TOKEN, etc.). Para las variables de entorno de Odoo en los servidores, usa archivos .env fuera del repositorio, montados en los containers via env_file en docker-compose.
El fichero odoo.conf que va en el repositorio nunca debe contener contraseñas reales. Las variables de entorno inyectadas al container sobreescriben las del fichero de configuración, que es el mecanismo correcto.
Control de versiones del workflow
Fija siempre las versiones de las GitHub Actions que usas (actions/checkout@v4, no actions/checkout@main). Una Action de terceros puede actualizar su main branch con código malicioso. Usar tags específicos o SHAs completos es la práctica de seguridad correcta en supply chain.
Paralelización de tests
Si el tiempo de ejecución de los tests crece (algo esperado en proyectos maduros), puedes paralelizar usando la estrategia matrix de GitHub Actions para distribuir los módulos entre varios jobs, o usando pytest-xdist para paralelizar tests dentro del mismo job. Con 50+ tests, la diferencia entre ejecución secuencial y paralela puede ser de 10 vs 3 minutos.
Rollback rápido
El rollback en Odoo es más complejo que en una aplicación sin estado. Si la nueva versión del código incluye migraciones de base de datos (nuevas columnas, columnas renombradas), no puedes simplemente volver al container anterior: la base de datos ya fue migrada y el código antiguo no la entiende.
Por eso el backup pre-deploy es obligatorio. El procedimiento de rollback completo es:
- Detener el container de Odoo.
- Restaurar el backup de base de datos tomado antes del deploy.
- Volver a lanzar el container con la imagen anterior (que está en el registro con su tag de SHA).
Este proceso debe estar documentado y testeado periódicamente. Un rollback que nunca se ha probado es un rollback que fallará cuando más se necesite.
El pipeline completo: resumen de tiempos
Un pipeline bien configurado para un proyecto Odoo de tamaño medio (10-20 módulos custom, 200-500 tests) tiene estos tiempos aproximados:
| Fase | Tiempo típico | Paralelizable |
|---|---|---|
| Lint (flake8 + pylint-odoo) | 1-2 min | Con test (en paralelo) |
| Tests (pytest-odoo) | 5-15 min | Con lint (en paralelo) |
| Build imagen Docker | 3-8 min (con caché: <2 min) | No (espera tests) |
| Deploy staging + migración | 3-6 min | No (espera build) |
| Smoke tests staging | 1-2 min | No (espera deploy staging) |
| Aprobación manual producción | Variable (humano) | — |
| Backup + deploy + migración prod | 5-10 min | No |
| Health check producción | 1-2 min | No |
Tiempo total desde push hasta producción: 20-45 minutos, con la mayor parte dedicada a tests y a la aprobación humana. Sin CI/CD, el mismo proceso manual de un desarrollador experimentado tarda entre 45 minutos y 2 horas, y con mucho más margen de error.
Conclusión
Un pipeline CI/CD para Odoo bien construido elimina la principal fuente de caídas en producción: el error humano en el despliegue manual. Cada vez que el pipeline detecta un fallo antes de llegar a producción —sea un test roto, un lint fallido, o una migración que no arranca— estás evitando una incidencia real. En proyectos donde una hora de caída cuesta miles de euros en operativa perdida, la inversión en automatización se amortiza en el primer incidente que no ocurre.
El workflow que he descrito aquí no es teórico: es el mismo esquema que uso en mis proyectos en producción, adaptado y simplificado para esta guía. Los detalles concretos (nombres de servicios, rutas, variables de entorno) varían por proyecto, pero la estructura de seis fases, la validación de backup, y el health check post-deploy son invariables.