Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
673525f
[FIX] runbot: sort versions on build error
d-fence Feb 2, 2026
f79688b
[IMP] runbot: add a visual warning on active field
d-fence Feb 2, 2026
781bfbc
[IMP] runbot: recompute error randomness on content update
pparidans Jan 21, 2026
b0a126e
[FIX] runbot: click/middle-click on FrontendUrl fields
pparidans Jan 5, 2026
74e95ad
[FIX] runbot: properly sort version on build error
d-fence Feb 5, 2026
0fbf1aa
[IMP] runbot: add support for check based on semgrep rules
Xavier-Do Jan 22, 2026
dc222a5
[IMP] error management
Xavier-Do Jul 30, 2025
ee5441b
[IMP] runbot: improve error merge
d-fence Feb 11, 2026
ee28177
[IMP] runbot: notify new root files
Xavier-Do Feb 11, 2026
ca30731
[FIX] runbt: adapt for ps dynamic
Xavier-Do Feb 4, 2026
d9a12af
[IMP] runbot: check write rights on runbot error fields
d-fence Feb 6, 2026
bde7732
[FIX] runbot: fetch threehash
Xavier-Do Dec 29, 2025
a1d3185
[FIX] runbot: fix crashing when a batch is preparing
d-fence Jan 26, 2026
20ad0c0
[IMP] runbot: display when triggers depends from another
Xavier-Do Jan 7, 2026
9770d6b
[FIX] runbot: fix drop database timeout
Xavier-Do Feb 12, 2026
2be6ea8
[IMP] runbot: add link to project's "next freeze"
pparidans Dec 18, 2025
d84dd67
[IMP] runbot: add a cache system for dockerfiles
d-fence Jan 6, 2026
f3116cb
[IMP] runbot: allow requests in server actions
Xavier-Do Feb 12, 2026
e07b568
[IMP] runbot: update default Chrome version
pparidans Feb 12, 2026
4a3004b
[FIX] runbot: better error handling during docker builds
d-fence Feb 13, 2026
503b1eb
[FIX] runbot: fix incorrect join
Xavier-Do Feb 16, 2026
d49a4ab
[IMP] runbot: allow to define if a trigger needs the version or not
Xavier-Do Feb 16, 2026
0f2b55a
[IMP] runbot: make params version_id optionnal
Xavier-Do Feb 18, 2026
c57a673
[IMP] runbot: allow a trigger to use an extra slot
Xavier-Do Feb 23, 2026
1fdf95a
[FIX] runbot: don't link rebase on builds
Xavier-Do Feb 16, 2026
b41e813
[IMP] runbot: add priority level to build_views
Xavier-Do Feb 26, 2026
14bf876
[IMP] runbot: add cache to some layers
Xavier-Do Feb 27, 2026
a5f08f9
[IMP] runbot: simplify backend urls
pparidans Feb 12, 2026
b535da3
[IMP] runbot: @odoo-module tag is default
pparidans Feb 12, 2026
8a14222
[IMP] runbot: remove unused js imports
pparidans Feb 12, 2026
87453c9
[REF] runbot: linting: string quoting in js
pparidans Feb 12, 2026
7f7cba3
[REF] runbot: linting: spacing and semi-colon in js
pparidans Feb 12, 2026
88273ae
[IMP] runbot: remove unused variable in js
pparidans Feb 12, 2026
82ab595
[REF] runbot: linting: Unexpected var, use let or const instead.
pparidans Feb 12, 2026
e4ab69d
[REF] runbot: linting: useless escaping in js
pparidans Feb 12, 2026
4c8f257
[IMP] runbot: easiets search on pr pull head name
Xavier-Do Mar 20, 2026
78fb162
[IMP] runbot: speedup dev=xml
Xavier-Do Mar 25, 2026
6fa5092
[IMP] runbot: allow to use lighter configs
Xavier-Do Mar 25, 2026
099282e
[IMP] runbot: don't enforce vesion if repo dosnet need it
Xavier-Do Mar 24, 2026
3ec0f7e
[IMP] runbot: allow slashes in branch names
Xavier-Do Mar 24, 2026
476ef6f
[FIX] runbot: do not concatenate refs_desc
Xavier-Do Mar 27, 2026
224ca81
[FIX] runbot: stats: main_trigger is None
pparidans Mar 27, 2026
77337ab
[FIX] runbot: hide custom triggers if users don't have read access
Xavier-Do Mar 27, 2026
4e9cf67
[IMP] runbot: enable light config by default
Xavier-Do Mar 30, 2026
a7afd75
[FIX] runbot: always build staging and base
Xavier-Do Mar 30, 2026
8df0e03
[FIX] runbot: fetch commit before making diff
Xavier-Do Mar 31, 2026
5a35127
[IMP] runbot: add a filter to only keep existing modules in a selection
Xavier-Do Mar 31, 2026
3f45ee4
[FIX] runbot don't apply default light config behaviour if we have a …
Xavier-Do Mar 31, 2026
8dde83f
[IMP] runbot: start build if forced and dependency explicitly disabled
Xavier-Do Mar 31, 2026
8f8db01
[IMP] runbot: improve light config interface
Xavier-Do Mar 31, 2026
b71869c
[FIX] runbot: fix public bundle page
Xavier-Do Apr 1, 2026
dbf5f43
[IMP] runbot: update default Chrome version (145)
pparidans Mar 5, 2026
336b66f
[FIX] runbot: btn-default styling, cleanup & fix active state
pparidans Mar 30, 2026
d70c363
[IMP] runbot: allow to filter on dependencies
Xavier-Do Feb 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion runbot/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
'author': "Odoo SA",
'website': "http://runbot.odoo.com",
'category': 'Website',
'version': '5.14',
'version': '5.16',
'application': True,
'depends': ['base', 'base_automation', 'website', 'auth_oauth'],
'data': [
Expand Down Expand Up @@ -57,6 +57,7 @@
'views/oauth_provider_views.xml',
'views/repo_views.xml',
'views/res_config_settings_views.xml',
'views/semgrep_rules.xml',
'views/stat_views.xml',
'views/upgrade.xml',
'views/upgrade_matrix_views.xml',
Expand Down
7 changes: 7 additions & 0 deletions runbot/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@
dest_reg = re.compile(r'^\d{5,}-.+$')


try:
from odoo.addons.saas_worker.util import from_role
except ImportError:
def from_role(*_, **__):
return lambda _: None


def transactioncache(method):
@functools.wraps(method)
def wrapper(self, *args, **kwargs):
Expand Down
7 changes: 5 additions & 2 deletions runbot/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def _docker_build(build_dir, image_tag, pull=False):
"""Build the docker image
:param build_dir: the build directory that contains Dockerfile.
:param image_tag: name used to tag the resulting docker image
:return: tuple(success, msg) where success is a boolean and msg is the error message or None
:return: dict
"""

with DockerManager(image_tag) as dm:
Expand Down Expand Up @@ -259,7 +259,10 @@ def _docker_run(cmd=False, log_path=False, build_dir=False, container_name=False
else:
run_cmd = cmd
run_cmd = f'cd /data/build;touch start-{container_name};{run_cmd};cd /data/build;touch end-{container_name}'
_logger.info('Docker run command: %s', run_cmd)
run_cmd_repr = str(run_cmd)
if len(run_cmd_repr) > 250:
run_cmd_repr = run_cmd_repr[:250] + '...'
_logger.info('Docker run command: %s', run_cmd_repr)
docker_clear_state(container_name, build_dir) # ensure that no state are remaining
build_dir = file_path(build_dir)

Expand Down
88 changes: 59 additions & 29 deletions runbot/controllers/frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def _pending(self):
'/runbot/<model("runbot.project"):project>',
'/runbot/<model("runbot.project"):project>/search/<search>'], website=True, auth='public', type='http')
def bundles(self, project=None, search='', refresh=False, limit=40, has_pr=None, **kwargs):
search = search if len(search) < 60 else search[:60]
search = search if len(search) < 60 else search[:200]
env = request.env
categories = env['runbot.category'].search([])
projects = self.env['runbot.project'].search([('hidden', '=', False)])
Expand Down Expand Up @@ -119,13 +119,11 @@ def bundles(self, project=None, search='', refresh=False, limit=40, has_pr=None,
pr_numbers = []
for search_elem in search.split("|"):
if search_elem.isnumeric():
pr_numbers.append(int(search_elem))
search_domains.append([('branch_ids', 'any', [('name', '=', search_elem)])])
if ':' in search_elem:
search_domains.append([('branch_ids', 'any', [('pull_head_name', '=', search_elem)])])
operator = '=ilike' if '%' in search_elem else 'ilike'
search_domains.append([('name', operator, search_elem)])
if pr_numbers:
res = request.env['runbot.branch'].search([('name', 'in', pr_numbers)])
if res:
search_domains.append([('id', 'in', res.mapped('bundle_id').ids)])
search_domain = Domain.OR(search_domains)
domain = Domain.AND([domain, search_domain])

Expand Down Expand Up @@ -166,7 +164,7 @@ def bundles(self, project=None, search='', refresh=False, limit=40, has_pr=None,
'/runbot/bundle/<model("runbot.bundle"):bundle>/page/<int:page>',
'/runbot/bundle/<string:bundle>',
], website=True, auth='public', type='http', sitemap=False)
def bundle(self, bundle=None, page=1, limit=50, **kwargs):
def bundle(self, bundle=None, page=1, limit=50, expand_custom=False, **kwargs):
if isinstance(bundle, str):
bundle = request.env['runbot.bundle'].search([('name', '=', bundle)], limit=1, order='id')
if not bundle:
Expand All @@ -183,13 +181,16 @@ def bundle(self, bundle=None, page=1, limit=50, **kwargs):
)
batchs = request.env['runbot.batch'].search(domain, limit=limit, offset=pager.get('offset', 0), order='id desc')

# compute if we should display the new batch button
context = {
'bundle': bundle,
'batchs': batchs,
'pager': pager,
'project': bundle.project_id,
'title': 'Bundle %s' % bundle.name,
'page_info_state': bundle.last_batch._get_global_result(),
'expand_custom': expand_custom,
'needs_update': bundle.last_batch and bundle.last_batch.sudo().needs_update(),
}

return request.render('runbot.bundle', context)
Expand All @@ -199,7 +200,7 @@ def bundle(self, bundle=None, page=1, limit=50, **kwargs):
'/runbot/bundle/<model("runbot.bundle"):bundle>/force/<int:auto_rebase>',
], type='http', auth="user", methods=['GET', 'POST'], csrf=False)
def force_bundle(self, bundle, auto_rebase=False, use_base_commits=False, **_post):
if not request.env.user.has_group('runbot.group_runbot_advanced_user') and ':' not in bundle.name:
if not request.env.user.has_group('runbot.group_runbot_advanced_user') and ':' not in bundle.name and not bundle.last_batch.needs_update():
message = "Only users with a specific group can do that. Please contact runbot administrators"
raise Forbidden(message)
_logger.info('user %s forcing bundle %s', request.env.user.name, bundle.name) # user must be able to read bundle
Expand All @@ -220,6 +221,12 @@ def batch(self, batch_id=None, **kwargs):
}
return request.render('runbot.batch', context)

@route(['/runbot/batch/<int:batch_id>/prioritize'], website=True, auth='user', type='http', sitemap=False)
def batch_priority(self, batch_id=None, **kwargs):
batch = request.env['runbot.batch'].browse(batch_id)
batch.sudo().priority_level = int(batch.create_date.timestamp() - 3600)
return werkzeug.utils.redirect('/runbot/batch/%s' % batch_id)

@route(['/runbot/batch/slot/<model("runbot.batch.slot"):slot>/build'], auth='user', type='http')
def slot_create_build(self, slot=None, **kwargs):
build = slot.sudo()._create_missing_build()
Expand Down Expand Up @@ -316,7 +323,8 @@ def build(self, build_id, search=None, from_batch=None, **post):
@route([
'/runbot/build/search',
], website=True, auth='public', type='http', sitemap=False)
def builds(self, **kwargs):
def builds(self, limit=100, **kwargs):
limit = min(int(limit), 1000)
domain = []
for key in ('config_id', 'version_id', 'project_id', 'trigger_id', 'create_batch_id.bundle_id', 'create_batch_id'): # allowed params
value = kwargs.get(key)
Expand All @@ -330,10 +338,12 @@ def builds(self, **kwargs):

for key in ('description',):
if key in kwargs:
domain.append((f'{key}', 'ilike', kwargs.get(key)))
value = kwargs.get(key)
operator = 'ilike' if '%' in value else '='
domain.append((f'{key}', operator, value))

context = {
'builds': request.env['runbot.build'].search(domain, limit=100),
'builds': request.env['runbot.build'].search(domain, limit=limit),
}

return request.render('runbot.build_search', context)
Expand Down Expand Up @@ -663,19 +673,40 @@ def parse_log(self, ir_log, **kwargs):
request.env['runbot.build.error']._parse_logs(ir_log)
return werkzeug.utils.redirect('/runbot/build/%s' % ir_log.build_id.id)

@route(['/runbot/bundle/toggle_no_build/<int:bundle_id>/<int:value>'], type='http', auth='user', sitemap=False)
def toggle_no_build(self, bundle_id, value, **kwargs):
if not request.env.user.has_group('base.group_user'):
return 'Forbidden'
bundle = request.env['runbot.bundle'].browse(bundle_id).exists()
if bundle.sticky or bundle.is_base:
return 'Forbidden'
if bundle.project_id.tmp_prefix and bundle.name.startswith(bundle.project_id.tmp_prefix):
return 'Forbidden'
bundle.sudo().no_build = bool(value)
_logger.info('Bundle %s no_build set to %s by %s', bundle.name, bool(value), request.env.user.name)
@route(['/runbot/bundle/<int:bundle_id>/triggers/<string:action>'], type='http', auth='user', sitemap=False)
def configure_bundle_triggers(self, bundle_id, action, expand_custom=False, **kwargs):
if not request.env.user.has_group('runbot.group_user'):
raise NotFound()

bundle = request.env['runbot.bundle'].browse(bundle_id)
if bundle.is_base or bundle.is_staging:
raise NotFound()
if action == 'disable_all':
bundle.sudo()._configure_custom_trigger_start_mode('disabled')
elif action == 'force_all':
bundle.sudo()._configure_custom_trigger_start_mode('force')
elif action == 'auto_all':
bundle.sudo()._configure_custom_trigger_start_mode('auto')
elif action == 'light_all':
bundle.sudo()._configure_custom_trigger_start_mode('light')
else:
raise NotFound()
if expand_custom:
return werkzeug.utils.redirect(f'/runbot/bundle/{bundle_id}?expand_custom=1')
return werkzeug.utils.redirect(f'/runbot/bundle/{bundle_id}')

@route(['/runbot/trigger_custom/<int:trigger_custom_id>/set_mode/<string:mode>'], type='http', auth='user', sitemap=False)
def configure_custom_trigger(self, trigger_custom_id, mode, **kwargs):
if not request.env.user.has_group('runbot.group_user'):
raise NotFound()
trigger_custom = request.env['runbot.bundle.trigger.custom'].browse(trigger_custom_id)
bundle = trigger_custom.bundle_id
if bundle.is_base or bundle.is_staging:
raise NotFound()

trigger_custom.sudo().start_mode = mode
return werkzeug.utils.redirect(f'/runbot/bundle/{trigger_custom.bundle_id.id}?expand_custom=1')

@route(['/runbot/trigger/report/<model("runbot.trigger"):trigger_id>'], type='http', auth='user', website=True, sitemap=False)
def report_view(self, trigger_id=None, **kwargs):
return request.render("runbot.trigger_report", {
Expand Down Expand Up @@ -852,21 +883,20 @@ def repos_heads(self, project_id=None, bundle_name=None, **kwargs):
else:
domain = Domain.AND([domain, [('sticky', '=', True)]])
bundles = request.env['runbot.bundle'].search(domain, order='id desc, name')

last_batches_infos = {
bundle.name: {
last_batches_infos = dict()
for bundle in bundles:
batch = bundle.last_batch if bundle.last_batch.state != 'preparing' else bundle.last_done_batch
last_batches_infos[bundle.name] = {
"commits": [
{
"repo": commit_link.commit_id.repo_id.name,
"head": commit_link.commit_id.name,
"match_type": commit_link.match_type,
}
for commit_link in bundle.last_batch.commit_link_ids
for commit_link in batch.commit_link_ids
],
"autotags": request.env["runbot.build.error"].sudo()._disabling_tags(build_id=bundle.last_batch.slot_ids.build_id[0]),
"autotags": request.env["runbot.build.error"].sudo()._disabling_tags(build_id=batch.slot_ids.build_id[0]),
}
for bundle in bundles
}
return request.make_json_response(last_batches_infos)

@route([
Expand Down
18 changes: 17 additions & 1 deletion runbot/controllers/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
import json
import logging

from odoo import http
from odoo import http, fields
from odoo.http import request
from ..common import from_role

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -50,3 +51,18 @@ def hook(self, remote_id=None, **_post):
branch = request.env['runbot.branch'].sudo().search([('remote_id', '=', remote.id), ('name', '=', branch_ref)])
branch.alive = False
return ""

@from_role('mergebot', signed=True)
@http.route(['/runbot/request_ci'], type='http', methods=["POST"], auth="public", website=True, csrf=False, sitemap=False)
def force_ci(self):
pull_request_names = request.get_json_data().get('pull_requests', [])
pull_domains = []
for pull_request_names in pull_request_names:
remote_short_name, name = pull_request_names.split('#')
owner, repo_name = remote_short_name.split('/')
pull_domains.append([('remote_id.owner', '=', owner), ('remote_id.repo_name', '=', repo_name), ('name', '=', name)])
pull_domains = fields.Domain.OR(pull_domains)
pull_requests = request.env['runbot.branch'].sudo().search([('is_pr', '=', True)] + pull_domains)
bundles = pull_requests.bundle_id
_logger.info('Received CI request for bundles: %s', bundles.mapped('name'))
bundles._force_ci()
6 changes: 4 additions & 2 deletions runbot/data/dockerfile_data.xml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
<field name="name">Install branch debian/control with latest postgresql-client</field>
<field name="values" eval="{'odoo_branch': 'master', 'os_release_name': '`lsb_release -s -c`'}"/>
<field name="content"># This layer updates the repository list to get the latest postgresql-client, mainly needed if the host postgresql version is higher than the default version of the docker os
# CACHE 60
ADD https://raw.githubusercontent.com/odoo/odoo/{odoo_branch}/debian/control /tmp/control.txt
RUN curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc -o /etc/apt/trusted.gpg.d/psql_client.asc \
&amp;&amp; echo "deb http://apt.postgresql.org/pub/repos/apt/ {os_release_name}-pgdg main" &gt; /etc/apt/sources.list.d/pgclient.list \
Expand All @@ -136,7 +137,7 @@ RUN curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc -o /etc/apt/tru
<field name="dockerfile_id" ref="runbot.docker_default"/>
<field name="layer_type">template</field>
<field name="name">Install chrome</field>
<field name="values" eval="{'chrome_version': '126.0.6478.182-1'}"/>
<field name="values" eval="{'chrome_version': '145.0.7632.116-1'}"/>
<field name="content">RUN curl -sSL https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_{chrome_version}_amd64.deb -o /tmp/chrome.deb \
&amp;&amp; apt-get update \
&amp;&amp; apt-get -y install --no-install-recommends /tmp/chrome.deb \
Expand Down Expand Up @@ -195,7 +196,8 @@ ENV PIP_BREAK_SYSTEM_PACKAGES=1</field>
<field name="layer_type">template</field>
<field name="name">Install branch requirements</field>
<field name="values" eval="{'odoo_branch': 'master'}"/>
<field name="content">ADD --chown={USERNAME} https://raw.githubusercontent.com/odoo/odoo/{odoo_branch}/requirements.txt /tmp/requirements.txt
<field name="content"># CACHE 60
ADD --chown={USERNAME} https://raw.githubusercontent.com/odoo/odoo/{odoo_branch}/requirements.txt /tmp/requirements.txt
RUN python3 -m pip install --no-cache-dir -r /tmp/requirements.txt</field>
</record>

Expand Down
28 changes: 25 additions & 3 deletions runbot/documentation/dynamic_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ The config steps are mainly defined by their `job_type`. The `name` key is also
```
The `db_name` is optionnal, usually set to all as a convention on runbot for databases that contains *almost* all modules. If not defined the sanitized version of the name will be used.

`install_modules` and `install_default_modules` behave the same way except that `install_modules` will consider that we start with no module (prepends `.*` filter) while `install_default_modules` will be based on the runbot default module list (all available modules minus the repo blacklist)
`install_modules` and `install_default_modules` behave the same way except that `install_modules` will consider that we start with no module (prepends `-*` filter) while `install_default_modules` will be based on the runbot default module list (all available modules minus the repo blacklist)

Both entries will use the value as a runbot module filter, and then passed as the -i, [see corresponding section](#module-selection) for more info.

Expand Down Expand Up @@ -344,21 +344,43 @@ Filters are a way to transform dynamic values before using them. They are define

For example, to transform a module filter into test tags:

#### filter_all_modules, make_module_test_tags

```json
{"test_tags": "-at_install,{{test_module_filter|filter_all_modules|make_module_test_tags}}",
```

In this example, the `filter_all_modules` filters will first transform the `test_module_filter` variable (which is a module filter) into a list of modules, and then the `make_module_test_tags` filters will transform this list of modules into test tags by prepending each module with a `/` to indicate that we want to run all tests from these modules.

Note that `filter_all_modules` is actually equivalent to `filter_default_modules`, but prepending a `*` at the begining of the filter.
#### filter_default_modules

`filter_all_modules` is actually equivalent to `filter_default_modules`, but prepending a `*` at the begining of the filter. Without that a runbot defined filter is applied, returning a default list of modules per repo.

`*,mail -> !web|filter_default_modules` is the same as `mail -> !web|filter_all_modules`


#### prepend, append
In some case we also want to combine the test-tags module with another tag or test method, this can be done using prepend and append

`"{{-*,web*|filter_all_modules|make_module_test_tags|append('.test_method')}}`
`{{-*,web*|filter_all_modules|make_module_test_tags|prepend('custom_tag')}}`


It is also possible to filter modules based on the one modified in the current bundle.
#### modified_modules

It is possible to filter modules based on the one modified in the current bundle.
`{{*|filter_all_modules|modified_modules}}"`

#### select_existing_modules

`select_existing_modules` is equivalent to `filter_default_modules` but with a -* at the beginning of the filter, meaning that we start with an empty selection and only add modules that are explicitly selected.

This is a solution to keep only existing modules from a specific list, when we are not sure modules exists:
`{{*|filter_all_modules|modified_modules|prepend('test_')|select_existing_modules|make_module_test_tags}}`

- `*|filter_all_modules` will select all existing modules
- `|modified_modules` will only keep the modified ones
- `prepend('test_')` will prepend test_ to have the test equivalent name of the modified modules (mail-> test_mail, base -> test_base)
- `select_existing_modules` will only keep modules that exists (test_mail)
- `make_module_test_tags` make the module test tags by prepending a / to each module.

Loading