ci: vendor qualify-addon.py (Pillar 1 self-contained)
All checks were successful
addon-qualify / qualify (push) Successful in 13s
All checks were successful
addon-qualify / qualify (push) Successful in 13s
This commit is contained in:
393
.gitea/qualify-addon.py
Normal file
393
.gitea/qualify-addon.py
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
qualify-addon.py — Pillar 1 of the addon qualification gate.
|
||||||
|
|
||||||
|
Static checks against an Odoo addon source tree:
|
||||||
|
manifest __manifest__.py parses, has 'name', 'version' starts with '<digit>.0.'
|
||||||
|
pip-deps every non-stdlib import is declared in external_dependencies['python']
|
||||||
|
app-name every <app> element in any view XML has a name= attribute
|
||||||
|
menu-icon top-level <menuitem web_icon=> is set OR the addon ships
|
||||||
|
static/description/icon.png
|
||||||
|
hoot-import no JS file under static/src/ imports from '@odoo/hoot' or '@odoo/hoot-dom'
|
||||||
|
webpack-name no JS file under static/lib/ uses self.webpackChunk_<unprefixed-name> —
|
||||||
|
chunk array names must be addon-namespaced (e.g. webpackChunk_am5_<addon>)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/qualify-addon.py <addon-dir> [<addon-dir> ...]
|
||||||
|
python3 scripts/qualify-addon.py --json <addon-dir>
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 all checks passed for every addon
|
||||||
|
1 at least one addon failed at least one check
|
||||||
|
2 bad usage / I/O error
|
||||||
|
|
||||||
|
Each finding is (severity, check, message). Severity:
|
||||||
|
ERROR — the addon is broken-by-construction; refuse to admit to catalog
|
||||||
|
WARN — likely problem but could be intentional; admit-with-warning posture
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
# Python stdlib modules. Conservative — anything imported NOT in here AND not in
|
||||||
|
# ODOO_BUILTINS is flagged as needing declaration. Better to false-positive (fixable
|
||||||
|
# by adding to external_dependencies) than miss a real missing dep.
|
||||||
|
STDLIB = frozenset({
|
||||||
|
'abc', 'argparse', 'ast', 'asyncio', 'base64', 'binascii', 'bisect', 'calendar',
|
||||||
|
'collections', 'configparser', 'contextlib', 'contextvars', 'copy', 'csv', 'ctypes',
|
||||||
|
'dataclasses', 'datetime', 'decimal', 'difflib', 'dis', 'email', 'enum', 'errno',
|
||||||
|
'fcntl', 'fnmatch', 'functools', 'gc', 'getpass', 'gettext', 'glob', 'gzip', 'hashlib',
|
||||||
|
'heapq', 'hmac', 'html', 'http', 'imaplib', 'importlib', 'inspect', 'io', 'ipaddress',
|
||||||
|
'itertools', 'json', 'keyword', 'locale', 'logging', 'math', 'mimetypes',
|
||||||
|
'multiprocessing', 'numbers', 'operator', 'os', 'pathlib', 'pickle', 'pkgutil',
|
||||||
|
'platform', 'pprint', 'queue', 'random', 're', 'select', 'selectors', 'shlex',
|
||||||
|
'shutil', 'signal', 'smtplib', 'socket', 'sqlite3', 'ssl', 'stat', 'string', 'struct',
|
||||||
|
'subprocess', 'sys', 'tempfile', 'textwrap', 'threading', 'time', 'timeit', 'token',
|
||||||
|
'tokenize', 'traceback', 'types', 'typing', 'unicodedata', 'unittest', 'urllib',
|
||||||
|
'uuid', 'warnings', 'weakref', 'xml', 'xmlrpc', 'zipfile', 'zlib', 'zoneinfo',
|
||||||
|
'__future__',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Modules shipped by Odoo's base image. Never need declaration.
|
||||||
|
ODOO_BUILTINS = frozenset({
|
||||||
|
'odoo', 'psycopg2', 'lxml', 'PIL', 'requests', 'dateutil', 'pytz', 'passlib',
|
||||||
|
'werkzeug', 'jinja2', 'markupsafe', 'docutils', 'reportlab', 'babel', 'xlsxwriter',
|
||||||
|
'xlrd', 'xlwt', 'qrcode', 'vobject', 'polib', 'PyPDF2', 'cryptography', 'pyOpenSSL',
|
||||||
|
'OpenSSL', 'suds', 'num2words', 'pyldap', 'ldap', 'xmltodict', 'zeep', 'gevent',
|
||||||
|
'greenlet', 'libsass', 'idna', 'pyusb', 'serial', 'qrcode', 'mock', 'freezegun',
|
||||||
|
'phonenumbers',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Bare-name webpackChunk arrays we know collide. Detector flags any
|
||||||
|
# `self.webpackChunk_<name>` where <name> doesn't have an addon-derived suffix.
|
||||||
|
# We allow the canonical chunk array names listed here ONLY if the addon's
|
||||||
|
# directory name matches — i.e. we suggest namespacing.
|
||||||
|
WEBPACK_CHUNK_RE = re.compile(r'self\.(webpackChunk[a-zA-Z0-9_]+)\b')
|
||||||
|
|
||||||
|
# JS imports of the hoot test framework that should never appear in production code.
|
||||||
|
HOOT_IMPORT_RE = re.compile(
|
||||||
|
r'''(?:from\s+['"]@odoo/hoot[a-z\-]*['"]|require\s*\(\s*['"]@odoo/hoot[a-z\-]*['"]\s*\))'''
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Finding:
|
||||||
|
severity: str # 'ERROR' | 'WARN'
|
||||||
|
check: str # short check id
|
||||||
|
message: str # human-readable
|
||||||
|
file: str | None = None # relative path, if applicable
|
||||||
|
line: int | None = None # 1-indexed, if applicable
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
# Check 1 — manifest parses + has required keys
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
def check_manifest(addon_dir: Path) -> tuple[list[Finding], dict | None]:
|
||||||
|
findings: list[Finding] = []
|
||||||
|
mf_path = addon_dir / '__manifest__.py'
|
||||||
|
if not mf_path.exists():
|
||||||
|
findings.append(Finding('ERROR', 'manifest', 'no __manifest__.py'))
|
||||||
|
return findings, None
|
||||||
|
try:
|
||||||
|
manifest = ast.literal_eval(mf_path.read_text())
|
||||||
|
except (SyntaxError, ValueError) as e:
|
||||||
|
findings.append(Finding('ERROR', 'manifest',
|
||||||
|
f'__manifest__.py does not parse as Python literal: {e}',
|
||||||
|
file='__manifest__.py'))
|
||||||
|
return findings, None
|
||||||
|
if not isinstance(manifest, dict):
|
||||||
|
findings.append(Finding('ERROR', 'manifest',
|
||||||
|
'__manifest__.py top-level is not a dict',
|
||||||
|
file='__manifest__.py'))
|
||||||
|
return findings, None
|
||||||
|
if not manifest.get('name'):
|
||||||
|
findings.append(Finding('ERROR', 'manifest',
|
||||||
|
"missing 'name' key (Odoo refuses install)",
|
||||||
|
file='__manifest__.py'))
|
||||||
|
version = manifest.get('version', '')
|
||||||
|
if not re.match(r'^\d+\.0\.\d+\.\d+\.\d+$', version):
|
||||||
|
findings.append(Finding('WARN', 'manifest',
|
||||||
|
f"version {version!r} is not in '<odoo_major>.0.x.y.z' form — "
|
||||||
|
"Odoo will prepend the running Odoo major and may refuse install "
|
||||||
|
"on a different major (incident #9)",
|
||||||
|
file='__manifest__.py'))
|
||||||
|
return findings, manifest
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
# Check 2 — pip deps: every non-stdlib import is in external_dependencies
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
def check_pip_deps(addon_dir: Path, manifest: dict) -> list[Finding]:
|
||||||
|
findings: list[Finding] = []
|
||||||
|
declared = set(manifest.get('external_dependencies', {}).get('python', []))
|
||||||
|
addon_name = addon_dir.name
|
||||||
|
|
||||||
|
# Pre-scan: collect this addon's submodule names so we don't flag intra-addon imports.
|
||||||
|
own_submodules = {p.stem for p in addon_dir.rglob('*.py') if p.stem != '__init__'}
|
||||||
|
own_submodules.add(addon_name)
|
||||||
|
|
||||||
|
seen_imports: set[tuple[str, str, int]] = set() # (toplevel, file, line)
|
||||||
|
|
||||||
|
for py_file in addon_dir.rglob('*.py'):
|
||||||
|
if any(part.startswith('.') for part in py_file.parts):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
tree = ast.parse(py_file.read_text())
|
||||||
|
except (SyntaxError, UnicodeDecodeError):
|
||||||
|
continue
|
||||||
|
rel = py_file.relative_to(addon_dir).as_posix()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
seen_imports.add((alias.name.split('.')[0], rel, node.lineno))
|
||||||
|
elif isinstance(node, ast.ImportFrom):
|
||||||
|
if node.level: # relative import — intra-addon, skip
|
||||||
|
continue
|
||||||
|
if node.module:
|
||||||
|
seen_imports.add((node.module.split('.')[0], rel, node.lineno))
|
||||||
|
|
||||||
|
for top, rel, lineno in sorted(seen_imports):
|
||||||
|
if top in STDLIB or top in ODOO_BUILTINS or top in own_submodules:
|
||||||
|
continue
|
||||||
|
if top in declared:
|
||||||
|
continue
|
||||||
|
# PEP 8 names that are clearly local helpers (e.g. utils, models) — skip if
|
||||||
|
# they look like a sibling module we missed in own_submodules.
|
||||||
|
if (addon_dir / top).is_dir() or (addon_dir / f'{top}.py').exists():
|
||||||
|
continue
|
||||||
|
findings.append(Finding(
|
||||||
|
'ERROR', 'pip-deps',
|
||||||
|
f"imports '{top}' but it is not in external_dependencies['python'] "
|
||||||
|
"(install will fail with ModuleNotFoundError — incident #5)",
|
||||||
|
file=rel, line=lineno,
|
||||||
|
))
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
# Check 3 — every <app> element has a name= attribute
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
def check_app_name(addon_dir: Path) -> list[Finding]:
|
||||||
|
findings: list[Finding] = []
|
||||||
|
# XML files in views/ + data/ may contain res_config_settings <app> elements.
|
||||||
|
for xml_file in list(addon_dir.rglob('views/*.xml')) + list(addon_dir.rglob('data/*.xml')):
|
||||||
|
try:
|
||||||
|
text = xml_file.read_text()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
rel = xml_file.relative_to(addon_dir).as_posix()
|
||||||
|
# Multi-line tolerant regex: <app ... > with everything between.
|
||||||
|
for m in re.finditer(r'<app\b[^>]*?>', text, re.DOTALL):
|
||||||
|
tag = m.group()
|
||||||
|
if 'name=' in tag:
|
||||||
|
continue
|
||||||
|
line = text[:m.start()].count('\n') + 1
|
||||||
|
findings.append(Finding(
|
||||||
|
'ERROR', 'app-name',
|
||||||
|
"<app> element missing name= attribute. Odoo 18 SettingsFormCompiler "
|
||||||
|
"calls toStringExpression(null) and crashes the entire Settings page "
|
||||||
|
"(incident #7)",
|
||||||
|
file=rel, line=line,
|
||||||
|
))
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
# Check 4 — top-level menus declare web_icon OR addon ships static/description/icon.png
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
def check_menu_icon(addon_dir: Path) -> list[Finding]:
|
||||||
|
findings: list[Finding] = []
|
||||||
|
has_default_icon = (addon_dir / 'static' / 'description' / 'icon.png').exists()
|
||||||
|
for xml_file in addon_dir.rglob('*.xml'):
|
||||||
|
try:
|
||||||
|
text = xml_file.read_text()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
rel = xml_file.relative_to(addon_dir).as_posix()
|
||||||
|
# Find <menuitem ... > whose XML has no parent= attribute (top-level menu).
|
||||||
|
for m in re.finditer(r'<menuitem\b[^>]*?/?>', text, re.DOTALL):
|
||||||
|
tag = m.group()
|
||||||
|
if 'parent=' in tag:
|
||||||
|
continue
|
||||||
|
if 'web_icon=' in tag:
|
||||||
|
continue
|
||||||
|
if has_default_icon:
|
||||||
|
# Odoo 18's auto-fallback path. Soft warning since it works for top-level
|
||||||
|
# menus that get web_icon auto-populated from the module's icon.png.
|
||||||
|
# But our incident #6 showed even with icon.png present, web_icon often
|
||||||
|
# ends up empty in DB. So WARN, not ERROR.
|
||||||
|
line = text[:m.start()].count('\n') + 1
|
||||||
|
findings.append(Finding(
|
||||||
|
'WARN', 'menu-icon',
|
||||||
|
"top-level <menuitem> has no web_icon=. Will fall back to "
|
||||||
|
"static/description/icon.png IF Odoo's auto-populate fires; "
|
||||||
|
"if not, menu shows blank (incident #6). Set web_icon explicitly.",
|
||||||
|
file=rel, line=line,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
line = text[:m.start()].count('\n') + 1
|
||||||
|
findings.append(Finding(
|
||||||
|
'ERROR', 'menu-icon',
|
||||||
|
"top-level <menuitem> has no web_icon= AND addon ships no "
|
||||||
|
"static/description/icon.png — menu will render blank.",
|
||||||
|
file=rel, line=line,
|
||||||
|
))
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
# Check 5 — no @odoo/hoot* imports in static/src/
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
def check_hoot_import(addon_dir: Path) -> list[Finding]:
|
||||||
|
findings: list[Finding] = []
|
||||||
|
src_dir = addon_dir / 'static' / 'src'
|
||||||
|
if not src_dir.exists():
|
||||||
|
return findings
|
||||||
|
for js_file in src_dir.rglob('*.js'):
|
||||||
|
try:
|
||||||
|
text = js_file.read_text()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
rel = js_file.relative_to(addon_dir).as_posix()
|
||||||
|
for m in HOOT_IMPORT_RE.finditer(text):
|
||||||
|
line = text[:m.start()].count('\n') + 1
|
||||||
|
findings.append(Finding(
|
||||||
|
'ERROR', 'hoot-import',
|
||||||
|
"imports from @odoo/hoot* in production code (static/src/). "
|
||||||
|
"@odoo/hoot is the test framework; the production bundle does not "
|
||||||
|
"register it. Page will white-screen (incident #3 class)",
|
||||||
|
file=rel, line=line,
|
||||||
|
))
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
# Check 6 — webpack chunk arrays in static/lib/ must be addon-namespaced
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
def check_webpack_chunk(addon_dir: Path) -> list[Finding]:
|
||||||
|
findings: list[Finding] = []
|
||||||
|
lib_dir = addon_dir / 'static' / 'lib'
|
||||||
|
if not lib_dir.exists():
|
||||||
|
return findings
|
||||||
|
addon_name = addon_dir.name
|
||||||
|
seen: set[str] = set()
|
||||||
|
for js_file in lib_dir.rglob('*.js'):
|
||||||
|
try:
|
||||||
|
text = js_file.read_text()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
rel = js_file.relative_to(addon_dir).as_posix()
|
||||||
|
for m in WEBPACK_CHUNK_RE.finditer(text):
|
||||||
|
chunk_name = m.group(1)
|
||||||
|
if chunk_name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(chunk_name)
|
||||||
|
# Acceptable if chunk name contains: full addon name OR any 4+ char
|
||||||
|
# sub-token of the addon name (e.g. 'ksdn' for 'ks_dashboard_ninja')
|
||||||
|
# OR a known-namespaced suffix (anything past the standard library
|
||||||
|
# prefix). We just need confidence the chunk array is unique-per-addon.
|
||||||
|
addon_lower = addon_name.lower()
|
||||||
|
chunk_lower = chunk_name.lower()
|
||||||
|
tokens = [addon_lower.replace('_', '')] + [
|
||||||
|
t for t in addon_lower.split('_') if len(t) >= 4
|
||||||
|
]
|
||||||
|
# Also accept any short 4+ char abbrev derived from initials of
|
||||||
|
# underscore-separated parts (ks_dashboard_ninja -> ksdn)
|
||||||
|
initials = ''.join(t[0] for t in addon_lower.split('_') if t)
|
||||||
|
if len(initials) >= 3:
|
||||||
|
tokens.append(initials)
|
||||||
|
if any(t in chunk_lower for t in tokens):
|
||||||
|
continue
|
||||||
|
line = text[:m.start()].count('\n') + 1
|
||||||
|
findings.append(Finding(
|
||||||
|
'ERROR', 'webpack-chunk',
|
||||||
|
f"uses bare webpack chunk array '{chunk_name}'. Two addons that ship "
|
||||||
|
f"the same library (e.g. amCharts) collide on this global → bundle "
|
||||||
|
f"execution aborts (incident #4). Rename to '{chunk_name}_{addon_name}' "
|
||||||
|
"or similar.",
|
||||||
|
file=rel, line=line,
|
||||||
|
))
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
# Runner
|
||||||
|
# ---------------------------------------------------------------------------- #
|
||||||
|
def qualify_addon(addon_dir: Path) -> dict:
|
||||||
|
findings: list[Finding] = []
|
||||||
|
|
||||||
|
manifest_findings, manifest = check_manifest(addon_dir)
|
||||||
|
findings.extend(manifest_findings)
|
||||||
|
|
||||||
|
if manifest is not None:
|
||||||
|
findings.extend(check_pip_deps(addon_dir, manifest))
|
||||||
|
|
||||||
|
findings.extend(check_app_name(addon_dir))
|
||||||
|
findings.extend(check_menu_icon(addon_dir))
|
||||||
|
findings.extend(check_hoot_import(addon_dir))
|
||||||
|
findings.extend(check_webpack_chunk(addon_dir))
|
||||||
|
|
||||||
|
errors = sum(1 for f in findings if f.severity == 'ERROR')
|
||||||
|
warns = sum(1 for f in findings if f.severity == 'WARN')
|
||||||
|
return {
|
||||||
|
'addon': addon_dir.name,
|
||||||
|
'path': str(addon_dir),
|
||||||
|
'qualified': errors == 0,
|
||||||
|
'errors': errors,
|
||||||
|
'warns': warns,
|
||||||
|
'findings': [asdict(f) for f in findings],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
json_out = False
|
||||||
|
args: list[str] = []
|
||||||
|
for a in argv[1:]:
|
||||||
|
if a == '--json':
|
||||||
|
json_out = True
|
||||||
|
elif a in ('-h', '--help'):
|
||||||
|
print(__doc__)
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
args.append(a)
|
||||||
|
if not args:
|
||||||
|
print(__doc__, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for path_str in args:
|
||||||
|
path = Path(path_str).resolve()
|
||||||
|
if not path.is_dir() or not (path / '__manifest__.py').exists():
|
||||||
|
print(f'ERROR: {path} is not an Odoo addon directory '
|
||||||
|
'(missing __manifest__.py)', file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
results.append(qualify_addon(path))
|
||||||
|
|
||||||
|
if json_out:
|
||||||
|
print(json.dumps(results, indent=2))
|
||||||
|
else:
|
||||||
|
for r in results:
|
||||||
|
badge = '\033[32mQUALIFIED\033[0m' if r['qualified'] else '\033[31mFAILED\033[0m'
|
||||||
|
print(f"\n{badge} {r['addon']} ({r['errors']} error(s), {r['warns']} warning(s))")
|
||||||
|
if not r['findings']:
|
||||||
|
continue
|
||||||
|
for f in r['findings']:
|
||||||
|
tag = '\033[31m' if f['severity'] == 'ERROR' else '\033[33m'
|
||||||
|
loc = ''
|
||||||
|
if f['file']:
|
||||||
|
loc = f" [{f['file']}" + (f":{f['line']}" if f['line'] else '') + ']'
|
||||||
|
print(f" {tag}{f['severity']:5}\033[0m {f['check']:<14} {f['message']}{loc}")
|
||||||
|
|
||||||
|
any_failed = any(not r['qualified'] for r in results)
|
||||||
|
return 1 if any_failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main(sys.argv))
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
# Pillar 1 of the addon-qualification proposal — runs on every push to any
|
# Pillar 1 of the addon-qualification proposal — runs on every push to any
|
||||||
# branch and on every PR. Pulls the latest qualify-addon.py from the v3
|
# branch and on every PR. Runs the vendored qualify-addon.py against every
|
||||||
# monorepo and runs it against every addon directory in this repo.
|
# addon directory in this repo.
|
||||||
#
|
#
|
||||||
# admit-with-warning posture: the workflow does NOT fail the build when an
|
# admit-with-warning posture: lint findings are reported but do NOT fail
|
||||||
# addon fails the lint. The result is published as a check annotation so
|
# the build (matches Pillar 3 informed-consent posture).
|
||||||
# operators can see it on the catalog card. (See docs/PROPOSAL_ADDON_QUALIFICATION.md
|
#
|
||||||
# in odoo-tower/odooskyv3 for the rationale.)
|
# To update the qualifier itself, edit scripts/qualify-addon.py in
|
||||||
|
# odoo-tower/odooskyv3 then sync it here.
|
||||||
name: addon-qualify
|
name: addon-qualify
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -20,12 +21,6 @@ jobs:
|
|||||||
- name: Checkout addons repo
|
- name: Checkout addons repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Fetch qualify-addon.py from v3 monorepo
|
|
||||||
run: |
|
|
||||||
curl -fsSL 'https://git.odoosky.org/odoo-tower/odooskyv3/raw/branch/main/scripts/qualify-addon.py' -o /tmp/qualify-addon.py
|
|
||||||
chmod +x /tmp/qualify-addon.py
|
|
||||||
head -1 /tmp/qualify-addon.py
|
|
||||||
|
|
||||||
- name: Run qualifier on every addon
|
- name: Run qualifier on every addon
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
@@ -38,9 +33,8 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Qualifying ${#ADDONS[@]} addons..."
|
echo "Qualifying ${#ADDONS[@]} addons..."
|
||||||
python3 /tmp/qualify-addon.py "${ADDONS[@]}"
|
python3 .gitea/qualify-addon.py "${ADDONS[@]}"
|
||||||
QUAL_RC=$?
|
QUAL_RC=$?
|
||||||
echo
|
echo
|
||||||
echo "::notice ::qualifier exit code $QUAL_RC (admit-with-warning — not failing build)"
|
echo "::notice ::qualifier exit code $QUAL_RC (admit-with-warning — not failing build)"
|
||||||
# Always succeed; the report is the artifact.
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
Reference in New Issue
Block a user