#!/usr/bin/env python3 """ emit-compat-bootstrap.py — Pillar 2 / Phase 4 emitter. Walks every addon directory under --addons-root, runs qualify-addon.py against each, and emits a consolidated JSON snapshot Tower's CompatSeedLoader replays into the matrix on startup. Wire shape (matches CompatSeedBootstrap in compat_seed_loader.go): { "stampId": "sha256:", "schemaVersion": 1, "source": "qualify-addon-v1", "rows": [ {"addonCode": "report_carbone", "addonVersion": "18.0.1.0.9", "odooMajor": "18.0", "postgresMajor": "16", "outcome": "success"}, ... ] } Usage (typical Gitea Actions invocation): python3 scripts/emit-compat-bootstrap.py \\ --addons-root . \\ --output compat-bootstrap/seeded-ci.json \\ --pg-by-major '{"18.0":"16","19.0":"17"}' Exit codes: 0 snapshot written successfully (file may or may not have changed) 2 bad usage / I/O error """ from __future__ import annotations import argparse import ast import hashlib import json import subprocess import sys from pathlib import Path # Static-lint check name → matrix error_class taxonomy. Aligns with # the keys in backend/cmd/api/error_translations.yaml so Phase 2 # aggregation can group static (seeded_ci) and runtime (real_install) # evidence under the same bucket. ERROR_CLASS_MAP = { 'manifest': 'manifest-malformed', 'pip-deps': 'pip-deps-missing', 'app-name': 'settings-page-broken', 'menu-icon': 'menu-icon-missing', 'hoot-import': 'bundle-module-missing', 'webpack-chunk': 'bundle-module-missing', } def find_addon_dirs(root: Path) -> list[Path]: """Return every direct child of `root` that ships a __manifest__.py. Skips dotfiles, .git, and the bootstrap output directory itself so re-running over a previously emitted tree doesn't try to lint JSON files. """ out: list[Path] = [] for child in sorted(root.iterdir()): if not child.is_dir(): continue if child.name.startswith('.') or child.name == 'compat-bootstrap': continue if (child / '__manifest__.py').is_file(): out.append(child) return out def parse_manifest_version(addon_dir: Path) -> tuple[str, str]: """Return (addonVersion, odooMajor). Best-effort: empty strings on parse failure. Odoo's convention is `...`, where odoo-major itself is `X.0` (e.g. `18.0`, `19.0`). We split on the first two dots → odoo_major = "18.0", addon_version = full. """ try: src = (addon_dir / '__manifest__.py').read_text(encoding='utf-8') manifest = ast.literal_eval(src) except Exception: return '', '' version = str(manifest.get('version', '')).strip() if not version: return '', '' parts = version.split('.') if len(parts) >= 2 and parts[0].isdigit() and parts[1] == '0': odoo_major = f'{parts[0]}.0' else: odoo_major = '' return version, odoo_major def run_qualifier(qualifier: Path, addon_dirs: list[Path]) -> list[dict]: """Invoke qualify-addon.py --json over every addon dir at once.""" if not addon_dirs: return [] cmd = ['python3', str(qualifier), '--json'] + [str(p) for p in addon_dirs] proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode == 2: # Bad usage / I/O — surface stderr and abort. sys.stderr.write(proc.stderr) sys.exit(2) # returncode 0 (all qualified) or 1 (some failed) both produce the # JSON array on stdout. Parse regardless. try: return json.loads(proc.stdout) except json.JSONDecodeError as e: sys.stderr.write(f'qualifier JSON parse failed: {e}\nstdout was:\n{proc.stdout}\n') sys.exit(2) def first_error_class(findings: list[dict]) -> str: """Pick the first ERROR-severity finding's check name and translate it through ERROR_CLASS_MAP. Returns '' if no ERROR-level finding.""" for f in findings: if f.get('severity') == 'ERROR': check = f.get('check', '') return ERROR_CLASS_MAP.get(check, check or 'unmatched') return '' def build_rows(qualifier_results: list[dict], manifests: dict[str, tuple[str, str]], pg_by_major: dict[str, str]) -> list[dict]: """Map qualifier output → matrix-shaped rows. Skips addons whose manifest didn't parse (no addonVersion) — the matrix needs both code and version to be useful, and we'd rather drop a row than seed it with empty strings the aggregator would later have to special-case. Dropped addons are reported on stderr so a regression in one __manifest__.py is visible (M4 fix). """ rows: list[dict] = [] dropped: list[str] = [] for r in qualifier_results: code = r['addon'] version, odoo_major = manifests.get(code, ('', '')) if not version or not odoo_major: dropped.append(code) continue pg_major = pg_by_major.get(odoo_major, '') if r.get('qualified'): outcome = 'success' error_class = '' else: outcome = 'failed_install' error_class = first_error_class(r.get('findings', [])) row = { 'addonCode': code, 'addonVersion': version, 'odooMajor': odoo_major, 'postgresMajor': pg_major, 'outcome': outcome, } if error_class: row['errorClass'] = error_class rows.append(row) rows.sort(key=lambda r: (r['addonCode'], r['addonVersion'], r['odooMajor'], r['postgresMajor'])) if dropped: sys.stderr.write( f'WARN: {len(dropped)} addon(s) dropped (manifest unparseable or version unrecognised): ' + ', '.join(dropped) + '\n') return rows def stamp_id(rows: list[dict]) -> str: """Content hash of the canonical row set. Same rows → same stamp, so Tower's idempotency check works across emitter invocations.""" canonical = json.dumps(rows, sort_keys=True, separators=(',', ':')) h = hashlib.sha256(canonical.encode('utf-8')).hexdigest() return f'sha256:{h}' def main(argv: list[str]) -> int: p = argparse.ArgumentParser() p.add_argument('--addons-root', required=True, type=Path, help='directory whose immediate children are addon dirs') p.add_argument('--qualifier', type=Path, default=Path(__file__).parent / 'qualify-addon.py', help='path to qualify-addon.py (defaults to sibling script)') p.add_argument('--output', required=True, type=Path, help='where to write the bootstrap JSON') p.add_argument('--pg-by-major', type=str, default='{"18.0":"16","19.0":"17"}', help='JSON map: odoo_major → recommended postgres_major') p.add_argument('--source', type=str, default='qualify-addon-v1', help='source identifier persisted in compat_seed_stamps.source') p.add_argument('--min-rows', type=int, default=0, help='minimum row count; emit failure exits non-zero (M2 sanity floor). ' 'Set per-major to catch silent qualifier breakage that would ship an empty seed.') args = p.parse_args(argv[1:]) if not args.addons_root.is_dir(): sys.stderr.write(f'addons-root not a directory: {args.addons_root}\n') return 2 if not args.qualifier.is_file(): sys.stderr.write(f'qualifier not found: {args.qualifier}\n') return 2 try: pg_by_major = json.loads(args.pg_by_major) except json.JSONDecodeError as e: sys.stderr.write(f'--pg-by-major must be JSON: {e}\n') return 2 addon_dirs = find_addon_dirs(args.addons_root) manifests = {d.name: parse_manifest_version(d) for d in addon_dirs} qualifier_results = run_qualifier(args.qualifier, addon_dirs) rows = build_rows(qualifier_results, manifests, pg_by_major) # M2 — refuse to ship a suspiciously small bootstrap. Caller picks # the floor based on expected catalog size; matching the floor with # one safety margin (e.g. branch has 10 addons → --min-rows 8) # catches "qualifier crashed mid-pass" without flapping on legit # one-or-two-addon removals. if args.min_rows > 0 and len(rows) < args.min_rows: sys.stderr.write( f'ERROR: produced {len(rows)} rows, expected at least {args.min_rows} — ' 'refusing to ship a thin bootstrap (set --min-rows lower if catalog has shrunk)\n') return 2 # No generatedAt field — it would change every run and defeat the # workflow's "git diff --quiet → no commit" idempotency. The git # commit timestamp + stampId in commit message carry the same info. bootstrap = { 'stampId': stamp_id(rows), 'schemaVersion': 1, 'source': args.source, 'rows': rows, } args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(bootstrap, indent=2) + '\n', encoding='utf-8') print(f'wrote {len(rows)} rows to {args.output} (stamp {bootstrap["stampId"]})', file=sys.stderr) return 0 if __name__ == '__main__': sys.exit(main(sys.argv))