1110 lines
No EOL
45 KiB
Markdown
1110 lines
No EOL
45 KiB
Markdown
---
|
||
name: odoo-testing-framework
|
||
description: >
|
||
Use this skill whenever the user is writing, reviewing, debugging, or planning tests
|
||
for an Odoo Community module. Trigger phrases: "write a test", "add a unit test",
|
||
"pytest-odoo", "TransactionCase", "BDD scenario", "Gherkin", "fixture", "conftest",
|
||
"test is failing", "coverage", "factory", "assertQueryCount", "pre-commit",
|
||
"CI pipeline", "template database", "Runboat", "Playwright", "E2E test",
|
||
"CLAUDE.md", "GitLab CI", "ArgoCD", "how do I test", "vibe code Odoo".
|
||
Also trigger when the user opens or edits any file matching
|
||
tests/test_*.py, features/*.feature, conftest.py, or .gitlab-ci.yml in an Odoo repo.
|
||
---
|
||
|
||
# Odoo Testing Framework Skill
|
||
|
||
This skill encodes the complete ITSulu testing framework for Odoo Community modules.
|
||
Read it top to bottom the first time you consult it in a session; return to specific
|
||
sections as needed during development.
|
||
|
||
---
|
||
|
||
## 0. The Golden Rules (read these first every time)
|
||
|
||
1. **Test first.** Write a failing test before any implementation code. Never write
|
||
implementation and tests at the same time.
|
||
2. **One behaviour per test.** Each test method verifies exactly one assertion.
|
||
The method name is the specification: `test_gold_tier_gets_ten_percent_discount`.
|
||
3. **Isolate with TransactionCase.** Unit tests roll back automatically. Never
|
||
clean up the database manually.
|
||
4. **Template databases, always.** Every CI job that runs tests must use a primed
|
||
template DB. Never install Odoo from scratch per test class.
|
||
5. **10–30 E2E scenarios, maximum.** Playwright is for critical user journeys only.
|
||
6. **BDD only when stakeholders read Gherkin.** If they don't, use TDD with
|
||
descriptive names. Do not generate Gherkin no-one will read.
|
||
7. **Mask all CI secrets.** Never commit tokens, passwords, or keys to YAML.
|
||
8. **Every addition is pain-driven.** Add a tool only when a concrete problem demands
|
||
it, not because the roadmap has a box for it.
|
||
|
||
---
|
||
|
||
## 1. Repository Layout
|
||
|
||
```
|
||
<repo-root>/
|
||
├── CLAUDE.md # Conventions file — Claude Code reads this
|
||
├── .pre-commit-config.yaml
|
||
├── .gitlab-ci.yml
|
||
├── addons/
|
||
│ └── <module>/
|
||
│ ├── __manifest__.py
|
||
│ ├── models/
|
||
│ ├── tests/
|
||
│ │ ├── __init__.py
|
||
│ │ ├── test_<feature>.py # TransactionCase unit tests
|
||
│ │ └── factories.py # OdooFactory for test data
|
||
│ └── features/ # Only if using BDD
|
||
│ └── <feature>.feature
|
||
├── e2e/ # Playwright scenarios
|
||
│ ├── conftest.py
|
||
│ └── test_<journey>.py
|
||
└── conftest.py # Template DB fixture lives here
|
||
```
|
||
|
||
---
|
||
|
||
## 2. TDD Cycle
|
||
|
||
```
|
||
RED → write test describing one behaviour (must fail)
|
||
GREEN → write minimum code to make it pass
|
||
REFACTOR → clean up; test stays green
|
||
REPEAT → next behaviour
|
||
```
|
||
|
||
Cycle time target: **5–15 minutes per behaviour**.
|
||
|
||
Never skip RED. Never write code before the test exists.
|
||
|
||
---
|
||
|
||
## 3. Writing pytest-odoo Tests
|
||
|
||
### 3.1 TransactionCase (default — use for almost everything)
|
||
|
||
```python
|
||
from odoo.tests import TransactionCase, tagged
|
||
|
||
@tagged('post_install', '-at_install', 'sale')
|
||
class TestSaleOrderDiscount(TransactionCase):
|
||
"""Loyalty discount rules on sale order lines."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
super().setUpClass()
|
||
# Expensive shared setup — runs once per class
|
||
cls.product = cls.env['product.product'].create({
|
||
'name': 'Widget', 'list_price': 100.0, 'type': 'consu'
|
||
})
|
||
|
||
def _make_order(self, tier):
|
||
"""Helper — always use helpers for repeated data creation."""
|
||
partner = self.env['res.partner'].create({
|
||
'name': f'Customer-{tier}', 'loyalty_tier': tier
|
||
})
|
||
return self.env['sale.order'].create({
|
||
'partner_id': partner.id,
|
||
'order_line': [(0, 0, {
|
||
'product_id': self.product.id,
|
||
'product_uom_qty': 1.0,
|
||
})]
|
||
})
|
||
|
||
def test_gold_tier_gets_ten_percent_discount(self):
|
||
order = self._make_order('gold')
|
||
self.assertEqual(order.order_line.discount, 10.0)
|
||
|
||
def test_silver_tier_gets_five_percent_discount(self):
|
||
order = self._make_order('silver')
|
||
self.assertEqual(order.order_line.discount, 5.0)
|
||
|
||
def test_no_tier_gets_no_discount(self):
|
||
order = self._make_order(False)
|
||
self.assertEqual(order.order_line.discount, 0.0)
|
||
```
|
||
|
||
**Rules:**
|
||
- Class name: `TestXxxYyy` — describes the unit under test.
|
||
- Method name: complete sentence — `test_what_when_condition`.
|
||
- `setUpClass`: shared, expensive objects (products, categories, pricelists).
|
||
- `setUp` / inline: test-specific data (partners, orders, invoices).
|
||
- Do **not** create shared mutable state; each test must be independent.
|
||
|
||
**Critical: Never call `self.env.cr.commit()` in code being tested by TransactionCase**
|
||
- TransactionCase uses database savepoints; each test gets rolled back automatically
|
||
- `commit()` breaks the savepoint chain, causing `InFailedSqlTransaction` errors in subsequent tests
|
||
- **Use `self.env.flush_all()` instead** to persist changes while keeping the test isolated
|
||
- If you need to test explicit commits, use `TestCase` instead (slower, no auto-rollback)
|
||
|
||
### 3.2 HttpCase (use rarely — only for controllers or UI tours)
|
||
|
||
```python
|
||
from odoo.tests import HttpCase
|
||
|
||
class TestSaleOrderUI(HttpCase):
|
||
def test_create_order_tour(self):
|
||
self.start_tour('/odoo/sales', 'sale_order_tour', login='admin')
|
||
```
|
||
|
||
Use HttpCase only when the test genuinely requires a running controller or JavaScript tour.
|
||
It is ~10× slower than TransactionCase.
|
||
|
||
### 3.3 Test tags
|
||
|
||
```python
|
||
@tagged('post_install', '-at_install') # Standard for module tests
|
||
@tagged('post_install', '-at_install', 'sale') # + module-specific tag
|
||
@tagged('post_install', '-at_install', 'performance') # Performance tests
|
||
```
|
||
|
||
Run subsets:
|
||
```bash
|
||
pytest addons/ -m sale # Only sale-tagged tests
|
||
pytest addons/ -m "not slow" # Exclude slow tests
|
||
```
|
||
|
||
### 3.4 Common assertions
|
||
|
||
```python
|
||
self.assertEqual(record.field, expected)
|
||
self.assertIn(item, collection)
|
||
self.assertTrue(condition)
|
||
self.assertFalse(condition)
|
||
self.assertRaises(ValidationError, lambda: record.write({'field': bad_value}))
|
||
|
||
# Performance assertion — always add to hot paths
|
||
with self.assertQueryCount(50):
|
||
records.action_confirm()
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Test Data Factories
|
||
|
||
Use factories, not demo data. Demo data changes; factories are explicit contracts.
|
||
|
||
```python
|
||
# addons/<module>/tests/factories.py
|
||
|
||
class OdooFactory:
|
||
def __init__(self, env):
|
||
self.env = env
|
||
self._seq = 0
|
||
|
||
def _next(self):
|
||
self._seq += 1
|
||
return self._seq
|
||
|
||
def partner(self, **kw):
|
||
vals = {'name': f'Partner-{self._next()}', 'email': f'p{self._next()}@test.com'}
|
||
vals.update(kw)
|
||
return self.env['res.partner'].create(vals)
|
||
|
||
def product(self, **kw):
|
||
vals = {'name': f'Product-{self._next()}', 'list_price': 100.0, 'type': 'consu'}
|
||
vals.update(kw)
|
||
return self.env['product.product'].create(vals)
|
||
|
||
def sale_order(self, partner=None, lines=None, **kw):
|
||
partner = partner or self.partner()
|
||
lines = lines or [{'product_id': self.product().id, 'product_uom_qty': 1}]
|
||
vals = {
|
||
'partner_id': partner.id,
|
||
'order_line': [(0, 0, l) for l in lines],
|
||
}
|
||
vals.update(kw)
|
||
return self.env['sale.order'].create(vals)
|
||
```
|
||
|
||
Use in tests:
|
||
```python
|
||
from .factories import OdooFactory
|
||
|
||
class TestMyFeature(TransactionCase):
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
super().setUpClass()
|
||
cls.factory = OdooFactory(cls.env)
|
||
|
||
def test_something(self):
|
||
order = self.factory.sale_order(lines=[
|
||
{'product_id': self.factory.product(list_price=200).id, 'product_uom_qty': 2}
|
||
])
|
||
self.assertEqual(order.amount_untaxed, 400.0)
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Template Database (conftest.py)
|
||
|
||
This is the single biggest speed optimisation. Always use it.
|
||
|
||
```python
|
||
# conftest.py (repo root)
|
||
import os
|
||
import pytest
|
||
import psycopg2
|
||
|
||
TEMPLATE_DB = os.environ.get('TEMPLATE_DATABASE', 'odoo_primed')
|
||
PG = dict(
|
||
host=os.environ.get('POSTGRES_HOST', 'localhost'),
|
||
user=os.environ.get('POSTGRES_USER', 'odoo'),
|
||
password=os.environ.get('POSTGRES_PASSWORD', 'odoo'),
|
||
dbname='postgres',
|
||
)
|
||
|
||
@pytest.fixture(scope='class')
|
||
def clone_db(request):
|
||
"""Clone the primed template database for this test class. Drop on teardown."""
|
||
db_name = f"test_{request.cls.__name__.lower()}_{os.getpid()}"
|
||
conn = psycopg2.connect(**PG)
|
||
conn.autocommit = True
|
||
conn.execute(f"CREATE DATABASE {db_name} TEMPLATE {TEMPLATE_DB}")
|
||
conn.close()
|
||
yield db_name
|
||
conn = psycopg2.connect(**PG)
|
||
conn.autocommit = True
|
||
conn.execute(f"DROP DATABASE IF EXISTS {db_name}")
|
||
conn.close()
|
||
```
|
||
|
||
CI before_script:
|
||
```yaml
|
||
before_script:
|
||
- createdb -h postgres -U odoo odoo_primed
|
||
- odoo -d odoo_primed -i sale,your_module --without-demo=all --stop-after-init
|
||
```
|
||
|
||
Expected speedup: **8–20× faster** test setup (typically ~12×).
|
||
|
||
---
|
||
|
||
## 6. BDD with pytest-bdd
|
||
|
||
Only add BDD when stakeholders read and contribute to feature files.
|
||
|
||
### 6.1 Feature file
|
||
|
||
```gherkin
|
||
# addons/sale_loyalty/features/loyalty_discount.feature
|
||
Feature: Loyalty discount on sale orders
|
||
As a sales manager
|
||
I want loyalty tier discounts applied automatically
|
||
So that high-value customers feel valued
|
||
|
||
Scenario Outline: Discount by tier
|
||
Given a customer with loyalty tier "<tier>"
|
||
When I create a sale order with 1 Widget
|
||
Then the order line discount should be <discount>%
|
||
|
||
Examples:
|
||
| tier | discount |
|
||
| gold | 10 |
|
||
| silver | 5 |
|
||
```
|
||
|
||
### 6.2 Step definitions
|
||
|
||
```python
|
||
# tests/test_loyalty_bdd.py
|
||
import pytest
|
||
from pytest_bdd import scenarios, given, when, then, parsers
|
||
|
||
scenarios('../features/loyalty_discount.feature')
|
||
|
||
# CRITICAL: pytest-bdd scenarios expect 'odoo_env' fixture, not pytest-odoo's 'env'.
|
||
# Use request.getfixturevalue('env') — direct env injection fails in pytest-bdd context:
|
||
@pytest.fixture
|
||
def odoo_env(request):
|
||
"""pytest-odoo's env fixture, re-exported for BDD step access."""
|
||
return env
|
||
|
||
@pytest.fixture
|
||
def ctx():
|
||
return {}
|
||
|
||
@given(parsers.parse('a customer with loyalty tier "{tier}"'), target_fixture='customer')
|
||
def make_customer(odoo_env, tier):
|
||
return odoo_env['res.partner'].create({'name': f'BDD-{tier}', 'loyalty_tier': tier})
|
||
|
||
@when(parsers.parse('I create a sale order with {qty:d} Widget'))
|
||
def create_order(odoo_env, customer, qty, ctx):
|
||
product = odoo_env['product.product'].search([('name', '=', 'Widget')], limit=1)
|
||
ctx['order'] = odoo_env['sale.order'].create({
|
||
'partner_id': customer.id,
|
||
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': qty})],
|
||
})
|
||
|
||
@then(parsers.parse('the order line discount should be {pct:d}%'))
|
||
def assert_discount(ctx, pct):
|
||
assert ctx['order'].order_line[0].discount == pct
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Playwright E2E
|
||
|
||
### 7.1 Core rules
|
||
|
||
- Maximum **10–30 scenarios**. Do not grow past this.
|
||
- Run against **Runboat live URLs**, never bootstrap Odoo inside the CI job.
|
||
- Selectors: `data-test-id` on custom views, `get_by_role`, `get_by_label`.
|
||
- Never use auto-generated Odoo CSS classes (`.o_form_view`, `.o_field_widget`, etc.).
|
||
- Add `data-test-id` to your XML views: `<field name="discount" data-test-id="line-discount"/>`.
|
||
- Set default timeout ≥ 30 seconds (Odoo JS is slow to load).
|
||
- Poll `BASE_URL + /web/login` until 200 before the suite starts (Runboat cold-start).
|
||
|
||
### 7.2 Session fixture
|
||
|
||
```python
|
||
# e2e/conftest.py
|
||
import pytest
|
||
import requests
|
||
import time
|
||
|
||
BASE_URL = os.environ['ODOO_BASE_URL'] # Set by CI from Runboat
|
||
|
||
def wait_for_odoo(url, timeout=180):
|
||
deadline = time.time() + timeout
|
||
while time.time() < deadline:
|
||
try:
|
||
r = requests.get(f"{url}/web/login", timeout=5)
|
||
if r.status_code == 200:
|
||
return
|
||
except Exception:
|
||
pass
|
||
time.sleep(5)
|
||
raise TimeoutError(f"Odoo at {url} not ready after {timeout}s")
|
||
|
||
@pytest.fixture(scope='session')
|
||
def browser_context_args(browser_context_args):
|
||
wait_for_odoo(BASE_URL)
|
||
return {**browser_context_args, 'base_url': BASE_URL}
|
||
|
||
@pytest.fixture(scope='session')
|
||
def auth_state(browser, browser_context_args):
|
||
ctx = browser.new_context(**browser_context_args)
|
||
page = ctx.new_page()
|
||
page.goto('/web/login')
|
||
page.get_by_label('Email').fill('admin')
|
||
page.get_by_label('Password').fill('admin')
|
||
page.get_by_role('button', name='Log in').click()
|
||
page.wait_for_url('**/odoo/**')
|
||
state = ctx.storage_state()
|
||
ctx.close()
|
||
return state
|
||
|
||
@pytest.fixture
|
||
def page(browser, browser_context_args, auth_state):
|
||
ctx = browser.new_context(**browser_context_args, storage_state=auth_state)
|
||
pg = ctx.new_page()
|
||
pg.set_default_timeout(30_000)
|
||
yield pg
|
||
ctx.close()
|
||
```
|
||
|
||
### 7.3 Example scenario
|
||
|
||
```python
|
||
# e2e/test_generation.py
|
||
from playwright.sync_api import expect
|
||
|
||
def test_user_generates_blog_post_on_demand(page):
|
||
"""Navigate to Blog Publisher, fill form, generate post, verify published."""
|
||
page.goto('/odoo/blog/generate-now')
|
||
page.wait_for_load_state('networkidle')
|
||
|
||
page.get_by_label('Topic').fill('Kubernetes Cost Optimization')
|
||
page.get_by_label('LLM Provider').select_option('anthropic')
|
||
page.get_by_label('Auto-publish').check()
|
||
|
||
page.get_by_role('button', name='Generate').click()
|
||
page.wait_for_url('**/blog/**', timeout=60_000)
|
||
|
||
expect(page.locator('h1')).to_contain_text('Kubernetes')
|
||
```
|
||
|
||
### 7.4 Runboat Integration
|
||
|
||
**Runboat** provides ephemeral preview instances of Odoo per CI commit:
|
||
- **Auto-deployment**: Fresh Odoo instance with addon pre-installed
|
||
- **Live URL**: For E2E tests (no local bootstrapping)
|
||
- **Auto-cleanup**: Instance removed 5 minutes after test completion
|
||
- **Template DB**: Primed once, cloned for each test
|
||
|
||
**Cold-start Handling**:
|
||
```python
|
||
def wait_for_odoo(url, timeout=180):
|
||
"""Poll until Odoo responds (instance startup takes 30-60s)."""
|
||
deadline = time.time() + timeout
|
||
while time.time() < deadline:
|
||
try:
|
||
if requests.get(f"{url}/web/login", timeout=5).status_code == 200:
|
||
return
|
||
except Exception:
|
||
pass
|
||
time.sleep(2)
|
||
raise TimeoutError(f"Odoo not ready after {timeout}s")
|
||
```
|
||
|
||
**CI/CD Integration**:
|
||
```yaml
|
||
# .gitlab-ci.yml
|
||
runboat_preview:
|
||
stage: preview
|
||
script: |
|
||
RESP=$(curl -fsSL -X POST $RUNBOAT_URL/builds \
|
||
-H "Authorization: Bearer $RUNBOAT_TOKEN" \
|
||
-d "{\"repo\":\"$CI_PROJECT_PATH\",\"sha\":\"$CI_COMMIT_SHA\"}")
|
||
BUILD_URL=$(echo "$RESP" | jq -r '.url')
|
||
echo "BUILD_URL=$BUILD_URL" >> build.env
|
||
|
||
e2e_tests:
|
||
stage: e2e
|
||
needs: [runboat_preview]
|
||
script: pytest e2e/ --base-url=$BUILD_URL -v
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Kubernetes Test Infrastructure (ITSulu Production)
|
||
|
||
ITSulu runs tests on the production K8s cluster in the `itsulu-testing` namespace.
|
||
Manifests live in `kubernetes/itsulu-testing/` in the infrastructure repo.
|
||
|
||
### 8.1 Architecture
|
||
|
||
```
|
||
itsulu-testing namespace
|
||
├── PostgreSQL 15 (deploy-test-db.yaml) # persistent, always running
|
||
│ └── odoo_template DB # primed once with all modules
|
||
├── K8s Secret: test-db-info # username, password, database keys
|
||
├── K8s Secret: gitlab-docker-creds # image pull from GitLab registry
|
||
└── Job: blog-publisher-bdd-test-<timestamp> # ephemeral per test run
|
||
├── initContainer: setup-test-db # postgres:15-alpine, createdb from template
|
||
└── container: test-runner # our Docker image, runs pytest
|
||
```
|
||
|
||
### 8.2 Docker Image (Dockerfile)
|
||
|
||
```dockerfile
|
||
FROM odoo:17.0
|
||
|
||
# odoo:17.0 runs as non-root 'odoo' user — pip installs go to ~/.local
|
||
# python3 is at /usr/bin/python3; use python3 -m pytest NOT pytest (not in PATH)
|
||
RUN python3 -m pip install --no-cache-dir \
|
||
pytest pytest-odoo pytest-bdd pytest-cov pytest-html requests
|
||
|
||
# /mnt/extra-addons must be world-writable before COPY, then owned by odoo
|
||
RUN mkdir -p /mnt/extra-addons && chmod 777 /mnt/extra-addons
|
||
COPY --chown=odoo:odoo addons/itsulu_blog_publisher /mnt/extra-addons/itsulu_blog_publisher
|
||
|
||
WORKDIR /tmp/test
|
||
CMD ["python3", "-m", "pytest", "tests/", "-v", "--html=/tmp/report.html", "--self-contained-html"]
|
||
```
|
||
|
||
**Critical gotchas learned:**
|
||
- `odoo:17.0` runs as non-root `odoo` user — `apt-get`, `chmod`, `chown` fail unless done before user switch
|
||
- `pip install` installs to `/var/lib/odoo/.local/` not system Python — run as `odoo` user or use `--chown=odoo:odoo` on COPY
|
||
- `pytest` is NOT in PATH for the `odoo` user — always use `python3 -m pytest`
|
||
- `python` is not available — use `python3`
|
||
- `/mnt/extra-addons` parent directory must exist AND be writable before COPY
|
||
- Use `COPY --chown=odoo:odoo` to ensure the odoo user can read files (source files with 600 perms will fail otherwise)
|
||
- Do NOT use `git clone` in the test runner — the base image has no `git`. The addon is already COPY'd into the image.
|
||
- `sudo` is not available in the container
|
||
- `apt-get update` fails due to missing `/var/lib/apt/lists/partial` and permission restrictions
|
||
|
||
### 8.3 Running a Test Job
|
||
|
||
```bash
|
||
# Create and run a test job (use a fixed DB name — not $RANDOM between containers)
|
||
TIMESTAMP=$(date +%s) && kubectl apply -f - <<EOF
|
||
apiVersion: batch/v1
|
||
kind: Job
|
||
metadata:
|
||
name: blog-publisher-bdd-test-${TIMESTAMP}
|
||
namespace: itsulu-testing
|
||
spec:
|
||
backoffLimit: 1
|
||
ttlSecondsAfterFinished: 3600
|
||
template:
|
||
spec:
|
||
serviceAccountName: test-runner
|
||
restartPolicy: Never
|
||
imagePullSecrets:
|
||
- name: gitlab-docker-creds
|
||
initContainers:
|
||
- name: setup-test-db
|
||
image: postgres:15-alpine
|
||
command: [sh, -c, |
|
||
until pg_isready -h test-db-svc -U $DB_USER; do sleep 2; done
|
||
PGPASSWORD=$DB_PASSWORD createdb -h test-db-svc -U $DB_USER -T odoo_template odoo_test || true]
|
||
env: [...] # DB_USER, DB_PASSWORD from test-db-info secret
|
||
containers:
|
||
- name: test-runner
|
||
image: registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:latest
|
||
imagePullPolicy: Always
|
||
command: [/bin/bash, -c, |
|
||
python3 -m pytest /mnt/extra-addons/itsulu_blog_publisher/tests -v
|
||
--odoo-database=odoo_test --html=/tmp/report.html --self-contained-html || true]
|
||
env: [...] # DB_USER, DB_PASSWORD from test-db-info secret
|
||
volumeMounts: [{name: test-results, mountPath: /tmp}]
|
||
volumes: [{name: test-results, emptyDir: {sizeLimit: 1Gi}}]
|
||
EOF
|
||
|
||
# Watch
|
||
kubectl get pods -n itsulu-testing -w
|
||
|
||
# Logs
|
||
kubectl logs -n itsulu-testing job/blog-publisher-bdd-test-${TIMESTAMP} -f
|
||
|
||
# Copy HTML report
|
||
kubectl cp itsulu-testing/<pod-name>:/tmp/report.html ./report.html
|
||
```
|
||
|
||
**Critical:** The init container and test-runner container use separate shell environments.
|
||
Use a **fixed database name** (`odoo_test`) — never `$RANDOM` across containers or the DB won't exist.
|
||
|
||
### 8.4 Manual Secrets (ExternalSecrets not installed)
|
||
|
||
```bash
|
||
# DB credentials
|
||
kubectl create secret generic test-db-info \
|
||
--from-literal=username=odoo_test \
|
||
--from-literal=password='<password>' \
|
||
--from-literal=database=odoo_template \
|
||
-n itsulu-testing
|
||
|
||
# GitLab registry credentials
|
||
kubectl create secret docker-registry gitlab-docker-creds \
|
||
--docker-server=registry.gitlab.com \
|
||
--docker-username=<gitlab-username> \
|
||
--docker-password=<gitlab-pat-token> \
|
||
-n itsulu-testing
|
||
```
|
||
|
||
### 8.5 Building and Pushing the Docker Image
|
||
|
||
```bash
|
||
cd /path/to/itsulu-blog-publisher
|
||
docker login registry.gitlab.com # use GitLab username + PAT token
|
||
|
||
# Build (use --no-cache when Dockerfile changes don't seem to apply)
|
||
docker build --no-cache -t registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:latest .
|
||
docker push registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:latest
|
||
```
|
||
|
||
---
|
||
|
||
## 9. GitLab CI Pipeline
|
||
|
||
```yaml
|
||
# .gitlab-ci.yml (skeleton)
|
||
stages: [lint, test, build, preview, e2e]
|
||
|
||
variables:
|
||
ODOO_IMAGE: registry.example.com/oca/oca-ci/py3.11-odoo17.0:latest
|
||
POSTGRES_DB: odoo_test
|
||
POSTGRES_USER: odoo
|
||
# POSTGRES_PASSWORD → set as masked CI/CD variable, never inline
|
||
|
||
lint:
|
||
stage: lint
|
||
image: $ODOO_IMAGE
|
||
script:
|
||
- pre-commit run --all-files
|
||
- pylint-odoo --rcfile=.pylintrc-odoo addons/
|
||
|
||
unit_tests:
|
||
stage: test
|
||
image: $ODOO_IMAGE
|
||
services:
|
||
- name: postgres:15
|
||
alias: postgres
|
||
command: ["postgres", "-c", "fsync=off", "-c", "shared_buffers=512MB"]
|
||
before_script:
|
||
- createdb -h postgres -U odoo odoo_primed
|
||
- odoo -d odoo_primed -i sale,your_module --without-demo=all --stop-after-init
|
||
script:
|
||
- pytest addons/ --odoo-database=odoo_primed --template-database=odoo_primed
|
||
--cov=addons --cov-report=xml --junitxml=report.xml
|
||
artifacts:
|
||
when: always
|
||
reports:
|
||
junit: report.xml
|
||
coverage_report: {coverage_format: cobertura, path: coverage.xml}
|
||
|
||
build_image:
|
||
stage: build
|
||
image: gcr.io/kaniko-project/executor:debug
|
||
script:
|
||
- /kaniko/executor --context=. --dockerfile=Dockerfile
|
||
--destination=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
|
||
rules:
|
||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||
|
||
runboat_build:
|
||
stage: preview
|
||
image: curlimages/curl:latest
|
||
script:
|
||
- |
|
||
RESP=$(curl -fsSL -X POST $RUNBOAT_URL/builds \
|
||
-H "Authorization: Bearer $RUNBOAT_TOKEN" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"repo":"'"$CI_PROJECT_PATH"'","sha":"'"$CI_COMMIT_SHA"'"}')
|
||
BUILD_URL=$(echo $RESP | jq -r .url)
|
||
echo "BUILD_URL=$BUILD_URL" >> build.env
|
||
- |
|
||
for i in $(seq 1 36); do
|
||
curl -sf "$BUILD_URL/web/login" && break || sleep 5
|
||
done
|
||
- 'curl -sf -X POST "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes"
|
||
-H "PRIVATE-TOKEN: $GITLAB_BOT_TOKEN"
|
||
--data "body=Preview: $BUILD_URL"'
|
||
artifacts:
|
||
reports: {dotenv: build.env}
|
||
rules:
|
||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||
|
||
playwright_e2e:
|
||
stage: e2e
|
||
image: mcr.microsoft.com/playwright/python:latest
|
||
needs: [runboat_build]
|
||
script:
|
||
- pip install -r e2e/requirements.txt
|
||
- pytest e2e/ --base-url=$BUILD_URL --tracing=retain-on-failure
|
||
artifacts:
|
||
when: on_failure
|
||
paths: [e2e/traces/]
|
||
expire_in: 1 week
|
||
rules:
|
||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||
```
|
||
|
||
### CI secrets — always mask
|
||
|
||
| Variable | Where to set | Notes |
|
||
|---|---|---|
|
||
| `POSTGRES_PASSWORD` | GitLab CI/CD variables | Masked |
|
||
| `RUNBOAT_TOKEN` | GitLab CI/CD variables | Masked + Protected |
|
||
| `GITLAB_BOT_TOKEN` | GitLab CI/CD variables | Masked + Protected |
|
||
| `CI_REGISTRY_PASSWORD` | Auto (GitLab) | Built-in |
|
||
|
||
Never put secrets in `.gitlab-ci.yml`. Never `echo $SECRET` in scripts.
|
||
|
||
---
|
||
|
||
## 10. Pre-commit Configuration
|
||
|
||
```yaml
|
||
# .pre-commit-config.yaml
|
||
repos:
|
||
- repo: https://github.com/OCA/pylint-odoo
|
||
rev: v9.1.0
|
||
hooks:
|
||
- id: pylint-odoo
|
||
additional_dependencies: [pylint-odoo]
|
||
- repo: https://github.com/psf/black
|
||
rev: 24.3.0
|
||
hooks:
|
||
- id: black
|
||
language_version: python3
|
||
- repo: https://github.com/pycqa/flake8
|
||
rev: 7.0.0
|
||
hooks:
|
||
- id: flake8
|
||
additional_dependencies: [flake8-bugbear]
|
||
- repo: https://github.com/pycqa/isort
|
||
rev: 5.13.2
|
||
hooks:
|
||
- id: isort
|
||
```
|
||
|
||
Run locally: `pre-commit run --all-files`
|
||
Run on commit: automatic after `pre-commit install`
|
||
|
||
---
|
||
|
||
## 11. CLAUDE.md Template
|
||
|
||
Copy this to the repo root and fill in the blanks. Claude Code reads this automatically.
|
||
|
||
```markdown
|
||
# CLAUDE.md
|
||
|
||
## Project
|
||
Odoo 17.0 Community — ITSulu
|
||
Framework: pytest-odoo (unit), pytest-bdd (BDD if stakeholders engage), Playwright (E2E)
|
||
Deploy: GitLab CI → Kaniko → ArgoCD → ITSulu Kubernetes
|
||
|
||
## Layout
|
||
addons/<module>/ Odoo addon code
|
||
addons/<module>/tests/ Unit tests (TransactionCase)
|
||
addons/<module>/features/ Gherkin features (BDD, when used)
|
||
e2e/ Playwright E2E scenarios
|
||
.gitlab-ci.yml Six-stage CI pipeline
|
||
|
||
## Development workflow — STRICT TDD
|
||
1. Write a failing test. Show it to me for approval.
|
||
2. I confirm the test matches intent before you write code.
|
||
3. Write minimum code to make it pass.
|
||
4. Run the full test class. Confirm green.
|
||
5. Refactor if needed. Test stays green.
|
||
6. Run pre-commit before considering the task done.
|
||
7. Never proceed to step 3 before step 2 is complete.
|
||
|
||
## Test naming
|
||
Class: TestXxxYyy (describes unit under test)
|
||
Method: test_what_when_condition (complete sentence, underscore-separated)
|
||
Good: test_gold_tier_customer_gets_ten_percent_discount
|
||
Bad: test_discount, test1, test_it_works
|
||
|
||
## When to use BDD
|
||
Only when a business stakeholder reads the feature file. Ask me first.
|
||
Default: TDD only with descriptive test names.
|
||
|
||
## Commands
|
||
pytest addons/ Run all tests
|
||
pytest addons/ -k TestSaleOrderDiscount Run one class
|
||
pytest addons/ -m "not slow" Exclude tagged tests
|
||
pytest e2e/ --base-url=$BUILD_URL E2E against Runboat
|
||
pre-commit run --all-files Lint + format
|
||
|
||
## Patterns to follow
|
||
- Use OdooFactory from tests/factories.py for record creation
|
||
- Use assertQueryCount on hot paths (confirm budget with me first)
|
||
- Add data-test-id to custom view fields used by Playwright
|
||
- One assertion per test method (exceptions need justification)
|
||
|
||
## Patterns to avoid
|
||
- Never create records using demo data or pre-seeded fixtures
|
||
- Never bypass pre-commit hooks
|
||
- Never silence test failures with try/except or xfail without issue ref
|
||
- Never write "assert True" or trivially-passing tests
|
||
- Never combine setup + assertion in one line
|
||
|
||
## When in doubt
|
||
Stop and ask me before: creating a new addon, modifying __manifest__,
|
||
changing a model that another module inherits, any sudo() or ir.rule change.
|
||
Show options with trade-offs. I will choose.
|
||
```
|
||
|
||
---
|
||
|
||
## 11.5 Odoo 17 Model Field Compatibility
|
||
|
||
When extending core Odoo models (e.g., `blog.post`), be aware of field differences:
|
||
|
||
| Model | Field | Odoo 16 | Odoo 17 | Note |
|
||
|-------|-------|---------|---------|------|
|
||
| `blog.post` | `body_arch` | Yes | **No** | Removed in Odoo 17 |
|
||
| `blog.post` | `body` | Yes | **No** | Removed in Odoo 17 |
|
||
| `blog.post` | `content` | — | **Yes** | The HTML body field in Odoo 17 — **writable**; auto-generation MUST write `'content': llm_response.body_html` or posts publish empty |
|
||
| `blog.post` | `itsulu_social_id` | N/A | Custom | Add via `_inherit` for reverse relationship to custom models |
|
||
| `mail.template` | subject / email_from | Mako `${}` | **inline_template `{{ }}`** | Mako `${}` is dead in Odoo 17 — renders literally |
|
||
| `mail.template` | body_html | Mako `${}`/`% for` | **qweb `type="html"`** | Use `<t t-out="">`, `<t t-foreach t-as>`, `<t t-if>` as real XML children. NOT `type="qweb"` (invalid RNG type) |
|
||
| `mail.template` | render method | `generate_email()` | **`_render_field(field, [ids])`** | Returns `{id: rendered_str}`; `_generate_template` returns UNrendered text |
|
||
|
||
**Pattern for extending blog.post safely:**
|
||
```python
|
||
# addons/<module>/models/blog_post.py
|
||
class BlogPost(models.Model):
|
||
_inherit = 'blog.post'
|
||
|
||
custom_field_id = fields.One2many(
|
||
comodel_name='custom.model',
|
||
inverse_name='blog_post_id',
|
||
string='Custom Records',
|
||
)
|
||
```
|
||
|
||
**Pattern for creating blog.post test records:**
|
||
```python
|
||
# Use sudo() to bypass Odoo validation when creating minimal test posts.
|
||
# The body field in Odoo 17 is `content` (NOT body / body_arch / body_html).
|
||
post = self.env['blog.post'].sudo().create({
|
||
'name': 'Test Post',
|
||
'blog_id': blog.id,
|
||
'is_published': True,
|
||
'content': '<p>HTML body goes here</p>', # Odoo 17 body field — writable
|
||
})
|
||
```
|
||
|
||
**Pattern for asserting on a rendered mail.template (synchronous):**
|
||
```python
|
||
template = self.env.ref('module.email_template_xmlid')
|
||
rendered = template._render_field('subject', [record.id]) # {id: 'rendered text'}
|
||
assert 'Expected' in rendered[record.id]
|
||
# body_html: template._render_field('body_html', [record.id])
|
||
```
|
||
|
||
---
|
||
|
||
## 12. Failure Recovery Quick Reference
|
||
|
||
| Symptom | Most likely cause | Fix |
|
||
|---|---|---|
|
||
| All tests fail with `relation does not exist` | Template DB corrupted / stale | Rebuild: `dropdb odoo_primed && createdb odoo_primed && odoo -d odoo_primed -i ...` |
|
||
| E2E first scenario fails, rest pass | Runboat not ready | Add readiness poll: `curl -sf $URL/web/login \|\| sleep 5` in a loop |
|
||
| E2E selector breaks after Odoo update | Auto-generated class changed | Replace with `data-test-id` attribute or `get_by_role` |
|
||
| Pipeline stuck "initializing" | Runboat cluster resource exhaustion | Increase namespace ResourceQuota; close stale MRs |
|
||
| Flaky test fails > twice/week | Non-deterministic test | Quarantine with `@pytest.mark.skip(reason="flaky #123")`; fix within 1 week |
|
||
| Secret appears in CI logs | Variable not masked | Rotate immediately; add to masked variables in GitLab |
|
||
| ArgoCD stuck OutOfSync | Manual cluster change | `argocd app diff`; revert manual change; re-sync |
|
||
| K8s Job: `PermissionError: __manifest__.py` | Files copied without `--chown=odoo:odoo` | Rebuild image with `COPY --chown=odoo:odoo` in Dockerfile |
|
||
| K8s Job: `No module named pytest` | Ran as wrong user (root); pytest in odoo user's ~/.local | Run container as odoo user (default); use `python3 -m pytest` not `pytest` |
|
||
| K8s Job: `database does not exist` | Init container used `$RANDOM` — different from test-runner | Use fixed DB name (`odoo_test`) shared between containers |
|
||
| K8s Job: `ErrImagePull` | `gitlab-docker-creds` secret missing or expired | Recreate: `kubectl create secret docker-registry gitlab-docker-creds ...` |
|
||
| K8s Job: `CreateContainerConfigError` | Secret key missing (e.g. `database` key not in `test-db-info`) | `kubectl describe pod <pod>` to find missing key; recreate secret with all 3 keys |
|
||
| Docker build: `chmod: Operation not permitted` | odoo:17.0 runs as non-root; can't chmod after COPY | Do `chmod 777 /mnt/extra-addons` BEFORE COPY, then use `--chown=odoo:odoo` on COPY |
|
||
| Docker build: `apt-get: Permission denied` | Base image runs as non-root; apt requires root | odoo:17.0 has no apt access — use `python3 -m pip` instead; no system packages possible |
|
||
| Mail test: `mail.mail.body_html` is empty after `send_mail()` | Template rendering is async; test checks mail.mail synchronously | Use `force_send=True` in send_mail(); check mail.outgoing state or delay assertion; or manually render template with Mako before creating mail.mail |
|
||
| Mail test: `mail.mail.subject` is `False` instead of string | Template subject field not rendered by send_mail() | Verify template fields are populated in DB; render manually if needed |
|
||
| Odoo 17: `blog.post` doesn't accept `body` or `body_arch` fields | Fields renamed/removed in Odoo 17 | Use factory pattern with `.sudo().create()` to bypass validation; post content should be managed via editor interface, not direct assignment |
|
||
| Odoo 17: Inverse relationship not loaded in template context | Relations lazy-load; mail.template context may not include reverse relations | Use explicit search in Mako (`for loop`) instead of relying on inverse field; or fetch record with prefetch in the render context |
|
||
| Odoo 17: `mail.template` has no `generate_email()` | Method removed in Odoo 17; API changed entirely | Use `template._generate_template([res_id], ['subject', 'body_html'])` — positional args only, returns `{res_id: {field: value}}`. Never use `generate_email(res_id)` (old Odoo 16 API) |
|
||
| conftest.py inside addon dir causes `Invalid import` | pytest adds addon dir to sys.path, bypassing `odoo.addons.*` namespace | Never put conftest.py inside the addon package. Place at repo root or at `/mnt/extra-addons/conftest.py` (parent of addon dir) |
|
||
| BDD test: `fixture 'env' not found` (pytest-odoo 2.x) | pytest-odoo 2.x does NOT provide an `env` pytest fixture — env only exists as `self.env` inside TransactionCase | In BDD step fixtures, build env directly: `registry = odoo.registry(request.config.getoption('--odoo-database')); with registry.cursor() as cr: env = Environment(cr, odoo.SUPERUSER_ID, {}); yield env; cr.rollback()` |
|
||
| Odoo 17: `mail.template` body uses `${}`/`% for` Mako and renders literally | Pre-Odoo-14 Mako syntax is dead in Odoo 17 | subject/email_from use inline_template `{{ }}`; body_html uses `type="html"` with qweb `<t t-out=""/>`, `<t t-foreach t-as>`, `<t t-if>`. NOT `type="qweb"` (invalid RNG type → "Element odoo has extra content: data") |
|
||
| Odoo 17: `mail.template._render_field('subject', [id])` returns `{id: rendered_str}` | This is the real synchronous render method | Use `_render_field(field, res_ids)` to assert on rendered output; `_generate_template` returns UNrendered template text |
|
||
| Data-XML change (e.g. mail.template) not reflected after image rebuild | Template DB was primed once; clones inherit stale records | Re-prime or run `odoo -u <module> -d odoo_template --stop-after-init`. Translatable fields (subject) DO update on `-u` once the XML is valid |
|
||
| Test passes alone but fails in full suite | Stale image at pod startup, or mock attr mismatch | Re-run fresh in the pod to confirm; a MagicMock attr the code reads but the test never set stringifies to ~66 chars (e.g. body_html vs text) |
|
||
| Odoo 17: `blog.post` has no `body_arch`/`body` | Renamed in Odoo 17 | The HTML body field is `content`. Auto-generation MUST write `'content': llm_response.body_html` or posts publish empty |
|
||
| Test: `IndexError: tuple index out of range` when accessing `mock.call_args[0][0]` | Mock method called with keyword-only args; `call_args[0]` is empty tuple `()` | Use `mock.call_args[1].get('key')` for kwargs; or check `mock.called` before accessing `call_args` |
|
||
| Test: TransactionCase gets `InFailedSqlTransaction` in subsequent tests | Previous test called `self.env.cr.commit()` breaking savepoint chain | Replace `commit()` with `flush_all()` in code being tested; `commit()` is only allowed in non-test code in production |
|
||
| Test: Mock response returns HTML but code expects JSON | Mock return values must match the data format expected by code under test | Create helper function to generate mocks with correct structure (e.g., JSON string in `.text` field for LLM routers) |
|
||
| Test: Calling mocked service with wrong parameter name | Test uses different parameter name than actual service signature | Match test calls to actual method signatures (e.g., `topic=` not `prompt=` for LLMRouter.generate()) |
|
||
| BDD test: `fixture 'env' not found` in odoo_env | pytest-bdd cannot inject pytest-odoo's `env` fixture by name into conftest fixtures | Use `request.getfixturevalue('env')` instead of `def odoo_env(env)` — and place the fixture in the BDD test file itself, not only in conftest; conftest in addon dirs triggers import errors |
|
||
|
||
---
|
||
|
||
## 13. Performance SLO Targets
|
||
|
||
### Test Infrastructure
|
||
|
||
| Metric | Target | Why |
|
||
|---|---|---|
|
||
| Unit test suite duration | < 3 min | Keeps TDD cycles tight |
|
||
| Full pipeline P50 | < 15 min | Fast enough to wait for |
|
||
| Full pipeline P95 | < 25 min | Outliers do not ruin the day |
|
||
| Flaky test rate | < 2%/week | Maintains trust in suite |
|
||
| Coverage on new code | ≥ 80% | Enforces test-first habit |
|
||
| Runboat cold-start P95 | < 120 s | E2E does not time out |
|
||
|
||
### Generation Performance (Phase 3)
|
||
|
||
| Metric | Target | Measurement |
|
||
|---|---|---|
|
||
| Generation latency P50 | < 30 seconds | RED → POST created |
|
||
| Generation latency P99 | < 60 seconds | 99th percentile |
|
||
| Tokens per post | 800–1200 | Cost baseline |
|
||
| Queries per generation | < 50 | N+1 detection |
|
||
| Concurrent posts | 5+ | Peak throughput |
|
||
| Email send latency | < 5 seconds | Notification speed |
|
||
| Template DB prime | < 60 seconds | CI/CD overhead |
|
||
|
||
**Measurement Tools**:
|
||
```python
|
||
# Latency profiling
|
||
import time
|
||
start = time.monotonic()
|
||
post = schedule.run_generation()
|
||
elapsed = time.monotonic() - start
|
||
assert elapsed < 30 # P50 target
|
||
|
||
# Query count assertion
|
||
with self.assertQueryCount(50):
|
||
schedule.run_generation() # Must use < 50 queries
|
||
|
||
# Token usage logging
|
||
log.tokens_used # Recorded in generation log
|
||
assert 800 <= log.tokens_used <= 1200
|
||
```
|
||
|
||
---
|
||
|
||
## 14. Production Deploy
|
||
|
||
---
|
||
|
||
## 15. Versioning & Releases
|
||
|
||
The project uses a three-part version number, **`MAJOR.MINOR.PATCH`**, each part `0–999`.
|
||
|
||
| Part | Bump when… | Examples |
|
||
|---|---|---|
|
||
| **MAJOR** | A major release for sale: significant feature upgrade, or a significant change to the software. | new product tier, rewrite, breaking redesign |
|
||
| **MINOR** | One or more features added, or a meaningful performance improvement. | new LLM provider, new wizard, 2× faster generation |
|
||
| **PATCH** | A single group of commits, or one large commit. | bug-fix batch, doc pass, refactor, dependency bump |
|
||
|
||
### Odoo version branches (one series per branch — the odoo/odoo model)
|
||
|
||
The addon targets **multiple Odoo Community releases, one per long-lived branch**, exactly like
|
||
[odoo/odoo](https://github.com/odoo/odoo) (`19.0`, `18.0`, …, `14.0`). Initially we ship:
|
||
|
||
| Branch | Odoo series | Why |
|
||
|---|---|---|
|
||
| `19.0` | Odoo Community 19.0 | Latest stable — primary supported line |
|
||
| `14.0` | Odoo Community 14.0 | The version ITSulu currently runs in production |
|
||
|
||
Rules:
|
||
|
||
- **Each Odoo series lives on its own branch.** Never mix two Odoo versions in one branch.
|
||
The branch name *is* the series (`19.0`, `14.0`).
|
||
- The **product version** (`VERSION`, e.g. `0.5.0`) is shared across series — the same feature
|
||
set, ported per Odoo release. The Odoo **manifest** version is `<series>.<product>` →
|
||
`19.0.0.5.0` on `19.0`, `14.0.0.5.0` on `14.0`.
|
||
- `bump-version.sh` resolves the series from `.odoo-series` (a one-line file at repo root),
|
||
else the branch name, else `17.0`. The `Dockerfile` base image (`FROM odoo:<series>`) and
|
||
the manifest prefix must match the branch.
|
||
- **Release tags are namespaced by series** so one product version ships on several branches:
|
||
`19.0-v0.5.0`, `14.0-v0.5.0`. (The pre-multi-version release `v0.4.8` predates this scheme.)
|
||
- A feature is "done" only when it lands on **every** supported series branch. Port a fix to
|
||
each branch (cherry-pick or re-implement) — Odoo APIs differ across 14 → 19, so a clean
|
||
cherry-pick is the exception, not the rule.
|
||
- Odoo-version-specific gotchas (mail.template syntax, `blog.post` fields, rendering APIs) are
|
||
in §11.5 and the §12 failure table. Always confirm which series you are on before fixing.
|
||
|
||
### Sources of truth & where the version lives
|
||
|
||
The repo-root **`VERSION`** file is the single source of truth. These are kept in sync:
|
||
|
||
| File | Form (example on branch `19.0`) | Notes |
|
||
|---|---|---|
|
||
| `VERSION` | `0.5.0` | Source of truth for the product version — plain text, one line. Same across series branches |
|
||
| `.odoo-series` | `19.0` | The Odoo series this branch targets (one line). Drives manifest prefix + tag namespace |
|
||
| `addons/itsulu_blog_publisher/__manifest__.py` | `'version': '19.0.0.5.0'` | Odoo manifest = `<series>.<product>` |
|
||
| `Dockerfile` | `FROM odoo:19.0` | Base image must match the series |
|
||
| `README.md` | `**Version:** 0.5.0` | Header line + status footer |
|
||
| `CHANGELOG.md` | `## v0.5.0 — <date>` | Release notes section per product version |
|
||
| git tag | `19.0-v0.5.0` | Annotated tag, namespaced by series; message = the CHANGELOG section |
|
||
|
||
**Never hand-edit the version in multiple files.** Run `bump-version.sh` on the target series
|
||
branch — it reads `VERSION` + the resolved series and updates `VERSION`, the manifest
|
||
(`<series>.<MAJOR>.<MINOR>.<PATCH>`), and README together.
|
||
|
||
### Release process (every version change)
|
||
|
||
```bash
|
||
# Run ON the target series branch (e.g. git checkout 19.0).
|
||
|
||
# 1. Bump the version (updates VERSION, manifest with the branch's series, README)
|
||
scripts/bump-version.sh patch # or: minor | major | set X.Y.Z
|
||
|
||
# 2. Write the release notes — add a "## vX.Y.Z — <date>" section to CHANGELOG.md
|
||
# in plain, common language (what changed, why it matters), NOT git-speak.
|
||
# Group under Fixed / Added / Changed / Testing & Infrastructure / Documentation.
|
||
|
||
# 3. Commit the bump + notes together
|
||
git add -A && git commit -m "release: vX.Y.Z"
|
||
|
||
# 4. Tag the release — names the tag <series>-vX.Y.Z, message from the CHANGELOG section
|
||
scripts/bump-version.sh tag
|
||
|
||
# 5. Push branch and the series-namespaced tag (e.g. 19.0-v0.5.0)
|
||
git push && git push origin "$(cat .odoo-series 2>/dev/null || echo 17.0)-v$(cat VERSION)"
|
||
|
||
# 6. Port the same change to the other series branches (14.0, …) and tag each there.
|
||
```
|
||
|
||
### Release-notes style (common language)
|
||
|
||
Write for a teammate, not a compiler. Each entry says **what changed and why it matters** in
|
||
one or two sentences of plain English. Prefer:
|
||
|
||
> - **Notification emails now render correctly.** They were going out with raw code in the
|
||
> subject instead of the post title; the template was rebuilt for Odoo 17.
|
||
|
||
over:
|
||
|
||
> - fix: migrate mail.template body_html ${} → qweb t-out
|
||
|
||
Keep the technical detail for commit messages and the CLAUDE.md failure table; the CHANGELOG is
|
||
the human-readable record. Group entries under **Fixed / Added / Changed / Testing &
|
||
Infrastructure / Documentation** as applicable.
|
||
|
||
### Rules
|
||
|
||
- One tag per `<series>` + product version; tags are immutable. If a release is wrong, ship the
|
||
next PATCH — never move a tag. (`v0.4.8` is the pre-multi-version exception.)
|
||
- Tag on the series branch **after** the work is merged/finalised, so the tag points at the
|
||
commit that actually ships.
|
||
- The Odoo manifest version must always be `<branch series>.` + the `VERSION` value (e.g.
|
||
`19.0.0.5.0`). The `-u` upgrade path relies on the manifest version increasing, so bump before
|
||
deploying schema/data changes.
|
||
- A change is not "released" until it is on **every** supported series branch with its own tag.
|
||
|
||
---
|
||
|
||
## 16. Phased Adoption Triggers
|
||
|
||
**Add Phase 2 (BDD, factories, coverage gates) when ≥ 2:**
|
||
- Stakeholders ask "how do we know X works?"
|
||
- Tests break on demo-data changes, not code changes
|
||
- Production N+1 regression caught post-deploy
|
||
- Coverage < 60% on touched files
|
||
|
||
**Add Phase 3 (Runboat) when ≥ 2:**
|
||
- Stakeholder asks "can I see it before merge?"
|
||
- Staging environment contention costs developer time
|
||
- Bugs escape CI that a human click-through would catch
|
||
|
||
**Add Phase 4 (Playwright, migration testing) when ≥ 2:**
|
||
- Production incident that automated testing would have caught
|
||
- Odoo upgrade requires emergency rollback
|
||
|
||
---
|
||
|
||
## 17. Infrastructure (ITSulu Cluster)
|
||
|
||
### Deployment topology
|
||
|
||
| Service | Namespace | URL |
|
||
|---------|-----------|-----|
|
||
| Blog Publisher (Odoo 19) | `blog-publisher` | `https://blog.itsulu.com` |
|
||
| Runboat CI | `runboat` | `https://runboat.itsulu.com` |
|
||
| Forgejo | `forgejo` | `https://git.itsulu.com` |
|
||
| Runboat build namespace | `itsulu-testing` | `*.runboat.itsulu.com` |
|
||
|
||
### Odoo 19 porting notes (learned 2026-05-30)
|
||
|
||
These fields/tags were removed in Odoo 17+ and will cause `ParseError` on first install if present:
|
||
|
||
| Old (≤16) | New (17+) | File type |
|
||
|-----------|-----------|-----------|
|
||
| `<tree>` | `<list>` | view XML |
|
||
| `view_mode="tree,form"` | `view_mode="list,form"` | action XML |
|
||
| `<group expand="0" string="Group By">` in `<search>` | `<separator/>` then bare `<filter>` | search view XML |
|
||
| `target="inline"` on `ir.actions.act_window` | `target="current"` | action XML |
|
||
| `numbercall` field on `ir.cron` | removed — omit entirely | data XML |
|
||
|
||
### Docker image build
|
||
|
||
The `19.0` Docker image is built manually from this repo root and pushed directly:
|
||
```bash
|
||
docker build -t registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:19.0 .
|
||
docker push registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:19.0
|
||
```
|
||
CI `build_image` job only runs on `main` and `merge_requests`. Branch `19.0` is built locally until a `main` branch is established or CI is updated.
|
||
|
||
### Secrets management
|
||
|
||
All secrets are in Vault (`itsulu/*` path), synced by ESO `ClusterSecretStore/css-vault`.
|
||
Namespaces must be listed in `kubernetes/eso/css.yaml` `spec.conditions[0].namespaces` to use ESO.
|
||
|
||
The `gitlab/dockerconfigjson` Vault key must store `dockerconfigjson` as the property name,
|
||
and the ESO ExternalSecret must use `secretKey: .dockerconfigjson` + `property: dockerconfigjson`
|
||
(NOT the `template.data` approach which adds a broken `{"dockerconfigjson":"..."}` wrapper).
|
||
|
||
### Runboat integration
|
||
|
||
The `runboat_tag` CI stage (`.gitlab-ci.yml`) fires on `19.0` and `main` after `build_image`:
|
||
1. Tags the commit-SHA image with the branch slug (`registry…:19-0`)
|
||
2. POSTs to `$RUNBOAT_BASE_URL/api/v1/builds` to trigger a live test instance
|
||
|
||
Runboat is a fork of `sbidoul/runboat` at `gitlab.com/itsulu-odoo/runboat` with two added
|
||
env vars: `RUNBOAT_FORGE_API_BASE_URL` and `RUNBOAT_FORGE_WEB_BASE_URL` (defaults to GitHub;
|
||
set to `https://git.itsulu.com/api/v1` and `https://git.itsulu.com` for Forgejo).
|
||
|
||
The upstream PR branch is `forgejo-configurable-base-url` on `gitlab.com/itsulu-odoo/runboat`.
|
||
- Team says "I don't trust the test suite" |