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
pytestsense 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ó:
- Lint i estil de codi — flake8, pylint-odoo, isort
- Tests unitaris i integració — pytest-odoo amb base de dades dedicada
- Build d'imatge Docker multi-stage — imatge optimitzada i signada
- Push a registre privat — GitHub Container Registry o Docker Hub privat
- Migració i deploy a staging — actualització de mòduls + smoke tests
- 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:
- Aturar el contenidor d'Odoo.
- Restaurar la còpia de seguretat de la base de dades feta abans del deploy.
- 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.