diff --git a/conf.py b/conf.py
index 68eca7146f7..e16ca0f3852 100644
--- a/conf.py
+++ b/conf.py
@@ -86,6 +86,7 @@
'sphinx_tabs.tabs',
'sphinx_rtd_theme',
'sphinx_sitemap_ros',
+ 'sphinx_adopters',
'sphinxcontrib.googleanalytics',
'sphinxcontrib.mermaid',
]
@@ -177,7 +178,8 @@
html_sourcelink_suffix = ''
# Relative to html_static_path
-html_css_files = ['custom.css']
+html_css_files = ['custom.css', 'adopters.css']
+html_js_files = ['adopters.js']
# -- Options for HTMLHelp output ------------------------------------------
diff --git a/plugins/adopters_schema.py b/plugins/adopters_schema.py
new file mode 100644
index 00000000000..d402a30e7db
--- /dev/null
+++ b/plugins/adopters_schema.py
@@ -0,0 +1,102 @@
+# Copyright 2026 Sony Group Corporation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Shared schema constants and validation for the ROS 2 adopters YAML file.
+This module has NO Sphinx dependency so it can be used in tests and CI scripts.
+
+NOTE: The domain constants defined here must be kept in sync with
+the corresponding values in source/_static/adopters.js (VALID_DOMAINS).
+Any additions or changes must be applied to both files.
+"""
+
+import re
+
+VALID_DOMAINS = [
+ 'Agriculture', # Farming, harvesting, crop monitoring, and precision agriculture
+ 'Aerial/Drone', # UAVs, drones, aerial inspection, and survey systems
+ 'Automotive', # Self-driving cars, ADAS, and ground vehicle autonomy
+ 'Components', # Robot parts and peripherals (cameras, LIDAR, RADAR, SONAR, etc)
+ 'Construction', # Site inspection, surveying, and construction automation
+ 'Consumer Robot', # Home robots, entertainment robots, and personal companions
+ 'Defense/Government', # Military, public safety, and national research programs
+ 'Education', # University courses, student projects, and teaching platforms
+ 'Energy', # Oil, gas, solar, nuclear, and power infrastructure operations
+ 'Healthcare/Medical', # Surgical robots, rehabilitation systems, and medical diagnostics
+ 'Humanoid', # Bipedal and human-form robots
+ 'Logistics/Warehouse', # AMRs, inventory management, and last-mile delivery systems
+ 'Manufacturing', # Industrial automation, assembly lines, and quality control
+ 'Marine', # Underwater, surface, and coastal robotic systems
+ 'Research', # General academic, laboratory, or experimental research
+ 'Space', # Planetary rovers, orbital systems, and space exploration
+ 'Service Robot', # Hospitality, cleaning, retail, and public-facing service robots
+]
+
+# YYYY-MM-DD format for date_added field.
+_DATE_ADDED_RE = re.compile(r'^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$')
+
+REQUIRED_FIELDS = [
+ 'organization',
+ 'project',
+ 'domain',
+ 'date_added',
+ 'country',
+ 'description'
+]
+
+
+def validate_adopters(adopters):
+ """Validate a list of adopter entries. Returns list of error strings."""
+ errors = []
+ if not isinstance(adopters, list):
+ return ["'adopters' must be a list"]
+ for i, entry in enumerate(adopters):
+ prefix = (
+ f'Entry {i + 1} '
+ f'({entry.get("organization", "unknown")}/{entry.get("project", "unknown")})'
+ )
+ if not isinstance(entry, dict):
+ errors.append(f'{prefix}: must be a mapping')
+ continue
+ for field in REQUIRED_FIELDS:
+ if field not in entry or not entry[field]:
+ errors.append(f'{prefix}: missing required field "{field}"')
+ if 'domain' in entry and entry['domain']:
+ if not isinstance(entry['domain'], list):
+ errors.append(f'{prefix}: "domain" must be a list')
+ else:
+ for d in entry['domain']:
+ if d not in VALID_DOMAINS:
+ errors.append(
+ f'{prefix}: invalid domain "{d}". '
+ f'Must be one of: {", ".join(VALID_DOMAINS)}'
+ )
+ if 'date_added' in entry and entry['date_added']:
+ date_str = str(entry['date_added'])
+ if not _DATE_ADDED_RE.match(date_str):
+ errors.append(
+ f'{prefix}: "date_added" must be in YYYY-MM-DD format, '
+ f'got "{date_str}"'
+ )
+ if 'country' in entry and entry['country']:
+ if not isinstance(entry['country'], list):
+ errors.append(f'{prefix}: "country" must be a list')
+ else:
+ for code in entry['country']:
+ if not isinstance(code, str) or len(code) != 2 or not code.isalpha():
+ errors.append(
+ f'{prefix}: each "country" entry must be a 2-letter '
+ f'ISO 3166-1 alpha-2 code, got "{code}"'
+ )
+ return errors
diff --git a/plugins/sphinx_adopters.py b/plugins/sphinx_adopters.py
new file mode 100644
index 00000000000..d5cff6daaa0
--- /dev/null
+++ b/plugins/sphinx_adopters.py
@@ -0,0 +1,199 @@
+# Copyright 2026 Sony Group Corporation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Sphinx extension to render and validate the ROS 2 adopters YAML file. """
+
+import os
+
+import yaml
+from docutils import nodes
+from docutils.parsers.rst import Directive
+from sphinx.errors import ExtensionError
+
+from adopters_schema import validate_adopters
+
+
+def _escape(text):
+ """Escape HTML special characters."""
+ if text is None:
+ return ''
+ return (
+ str(text)
+ .replace('&', '&')
+ .replace('<', '<')
+ .replace('>', '>')
+ .replace('"', '"')
+ )
+
+
+def _make_link(text, url):
+ """Create an HTML link if url is provided, otherwise plain text."""
+ escaped_text = _escape(text)
+ if url:
+ escaped_url = _escape(url)
+ return f'{escaped_text}'
+ return escaped_text
+
+
+class AdoptersTableDirective(Directive):
+ """Directive to render the adopters YAML as a filterable HTML table."""
+
+ has_content = False
+ required_arguments = 0
+ optional_arguments = 0
+ option_spec = {}
+
+ def run(self):
+ env = self.state.document.settings.env
+ # Locate adopters.yaml relative to the source file containing the directive.
+ source_dir = os.path.dirname(env.doc2path(env.docname))
+ yaml_path = os.path.join(source_dir, 'adopters.yaml')
+
+ if not os.path.isfile(yaml_path):
+ raise ExtensionError(
+ f'adopters.yaml not found at {yaml_path}'
+ )
+
+ # Register adopters.yaml as a dependency so incremental builds
+ # detect changes and re-read the directive.
+ env.note_dependency(yaml_path)
+
+ with open(yaml_path, 'r') as f:
+ data = yaml.safe_load(f)
+
+ adopters = data.get('adopters', [])
+ errors = validate_adopters(adopters)
+ if errors:
+ raise ExtensionError(
+ 'Adopters YAML validation failed:\n' + '\n'.join(f' - {e}' for e in errors)
+ )
+
+ # Sort by date_added descending (newest first), then organization name (A-Z).
+ adopters.sort(key=lambda a: a.get('organization', '').lower())
+ adopters.sort(key=lambda a: a.get('date_added', ''), reverse=True)
+
+ # Collect unique values for filters.
+ all_domains = sorted({d for a in adopters for d in a.get('domain', [])})
+ all_countries = sorted({c for a in adopters for c in a.get('country', [])})
+
+ # Build HTML.
+ html_parts = []
+
+ # Filter controls.
+ html_parts.append('
')
+ html_parts.append('')
+ html_parts.append('')
+
+ html_parts.append('')
+ html_parts.append('')
+
+ html_parts.append('')
+ html_parts.append(
+ ''
+ )
+
+ # Show-all-history toggle.
+ html_parts.append(
+ ''
+ ''
+ 'Showing entries from the past 3 years. '
+ 'Check “Show all history” to see all entries.'
+ ''
+ )
+
+ html_parts.append('
')
+
+ # Table.
+ html_parts.append('
')
+ html_parts.append('
')
+ html_parts.append('
Organization
')
+ html_parts.append('
Project
')
+ html_parts.append('
Domain
')
+ html_parts.append('
Date Added
')
+ html_parts.append('
Country
')
+ html_parts.append('
Description
')
+ html_parts.append('
')
+ html_parts.append('')
+
+ for adopter in adopters:
+ org = _make_link(
+ adopter.get('organization', ''),
+ adopter.get('organization_url'),
+ )
+ project = _make_link(
+ adopter.get('project', ''),
+ adopter.get('project_url'),
+ )
+ domains = ', '.join(adopter.get('domain', []))
+ date_added = adopter.get('date_added', '')
+ countries = adopter.get('country', [])
+ country_str = ', '.join(countries)
+ description = _escape(adopter.get('description', ''))
+
+ # Data attributes for filtering.
+ data_domains = ' '.join(_escape(d) for d in adopter.get('domain', []))
+ data_countries = ' '.join(_escape(c) for c in countries)
+ html_parts.append(
+ f'
'
+ )
+ html_parts.append(f'
{org}
')
+ html_parts.append(f'
{project}
')
+ html_parts.append(f'
{_escape(domains)}
')
+ html_parts.append(f'
{_escape(str(date_added))}
')
+ html_parts.append(f'
{_escape(country_str)}
')
+ html_parts.append(f'
{description}
')
+ html_parts.append('
')
+
+ html_parts.append('
')
+
+ raw_node = nodes.raw('', '\n'.join(html_parts), format='html')
+ return [raw_node]
+
+
+_ADOPTERS_DOCNAME = 'The-ROS2-Project/Adopters/Adopters'
+
+
+def _get_outdated(app, env, _added, _changed, _removed):
+ """Force rebuild of the Adopters page when adopters.yaml changes."""
+ yaml_path = os.path.join(app.srcdir, 'The-ROS2-Project', 'Adopters', 'adopters.yaml')
+ if not os.path.isfile(yaml_path):
+ return []
+ doctree_path = os.path.join(app.doctreedir, _ADOPTERS_DOCNAME + '.doctree')
+ if os.path.isfile(doctree_path):
+ if os.path.getmtime(yaml_path) > os.path.getmtime(doctree_path):
+ return [_ADOPTERS_DOCNAME]
+ return []
+
+
+def setup(app):
+ app.add_directive('adopters-table', AdoptersTableDirective)
+ app.connect('env-get-outdated', _get_outdated)
+ return {
+ 'version': '0.1',
+ 'parallel_read_safe': True,
+ 'parallel_write_safe': True,
+ }
diff --git a/source/The-ROS2-Project.rst b/source/The-ROS2-Project.rst
index 8f4b8ef66df..5009041fe61 100644
--- a/source/The-ROS2-Project.rst
+++ b/source/The-ROS2-Project.rst
@@ -17,3 +17,5 @@ Check out the resources below to learn more about the advancement of the ROS 2 p
The-ROS2-Project/Release-Schedule
The-ROS2-Project/Marketing
The-ROS2-Project/Metrics
+ The-ROS2-Project/Adopters/Adopters
+ The-ROS2-Project/Adopters/Add-Your-Project
diff --git a/source/The-ROS2-Project/Adopters/Add-Your-Project.rst b/source/The-ROS2-Project/Adopters/Add-Your-Project.rst
new file mode 100644
index 00000000000..f046f5a5309
--- /dev/null
+++ b/source/The-ROS2-Project/Adopters/Add-Your-Project.rst
@@ -0,0 +1,119 @@
+Add Your Project
+================
+
+Use the form below to generate the YAML entry for your organization or project.
+Once generated, you can copy the YAML snippet and submit a pull request to the
+`adopters.yaml `__
+file on the ``rolling`` branch.
+
+Policy
+------
+
+This list is **self-reported and self-attested**.
+Entries are accepted with minimal scrutiny unless a complaint is received.
+Since contributions come via Pull Request, they are easy to audit and can be cleaned up later if necessary.
+
+How to contribute
+-----------------
+
+1. Fill in the form below.
+2. Click **Generate YAML** to produce the snippet.
+3. Click **Open PR on GitHub** to open the file in GitHub's web editor (the YAML is copied to your clipboard automatically).
+4. Paste the generated YAML at the end of the ``adopters:`` list in the file.
+5. Commit the change and open a pull request.
+
+.. note::
+
+ All pull requests to the ROS 2 documentation repository require a
+ `Developer Certificate of Origin (DCO) `__ sign-off.
+ If you use the GitHub web editor, the
+ `DCO bot `__ will comment on your PR with
+ instructions to add the sign-off if it is missing.
+ To sign off via the command line, use ``git commit --signoff``.
+
+.. raw:: html
+
+
+
+
diff --git a/source/The-ROS2-Project/Adopters/Adopters.rst b/source/The-ROS2-Project/Adopters/Adopters.rst
new file mode 100644
index 00000000000..a90273018d1
--- /dev/null
+++ b/source/The-ROS2-Project/Adopters/Adopters.rst
@@ -0,0 +1,10 @@
+ROS 2 Adopters
+==============
+
+This page showcases organizations and projects using ROS in any capacity.
+It is a community-maintained, self-reported directory.
+If you use ROS, we encourage you to add your project.
+
+To add your organization or project, see :doc:`Add-Your-Project`.
+
+.. adopters-table::
diff --git a/source/The-ROS2-Project/Adopters/adopters.yaml b/source/The-ROS2-Project/Adopters/adopters.yaml
new file mode 100644
index 00000000000..f15044ab026
--- /dev/null
+++ b/source/The-ROS2-Project/Adopters/adopters.yaml
@@ -0,0 +1,67 @@
+# ROS 2 Adopters
+#
+# This file is the source of truth for the ROS 2 Adopters page.
+# To add your organization/project, submit a PR to the rolling branch.
+# Use the YAML generator at https://docs.ros.org/en/rolling/The-ROS2-Project/Adopters/Add-Your-Project.html
+#
+# Schema:
+# organization (required): Company or institution name
+# organization_url (optional): URL of the organization
+# project (required): The specific project using ROS
+# project_url (optional): URL of the project
+# domain (required): List of industry categories from the enumerated values
+# date_added (required): Date the entry was added, in YYYY-MM-DD format (auto-generated)
+# country (required): List of ISO 3166-1 alpha-2 country codes
+# description (required): Brief explanation of how they use ROS
+#
+# Domain values:
+# Agriculture, Aerial/Drone, Automotive, Components, Construction, Consumer Robot,
+# Defense/Government, Education, Energy, Healthcare/Medical, Humanoid,
+# Logistics/Warehouse, Manufacturing, Marine, Research, Space, Service Robot
+
+adopters:
+ - organization: "Sony Group Corporation"
+ organization_url: "https://www.sony.com"
+ project: "aibo"
+ project_url: "https://aibo.sony.jp"
+ domain:
+ - Consumer Robot
+ date_added: "2026-03-25"
+ country:
+ - JP
+ description: "An autonomous robotic companion dog that develops its own unique personality."
+
+ - organization: "Sony Group Corporation"
+ organization_url: "https://www.sony.com"
+ project: "poiq"
+ project_url: "https://www.sony.com/en/brand/futureproof/project/poiq/"
+ domain:
+ - Consumer Robot
+ date_added: "2026-03-25"
+ country:
+ - JP
+ description: "Small AI entertainment robot with conversational capabilities."
+
+ - organization: "Sony Group Corporation"
+ organization_url: "https://www.sony.com"
+ project: "Airpeak S1"
+ project_url: "https://www.sony.com/en/articles/product-specifications-airpeak-s1"
+ domain:
+ - Aerial/Drone
+ date_added: "2026-03-25"
+ country:
+ - JP
+ description: "Professional drone carrying full-frame mirrorless cameras."
+
+ - organization: "Sony Semiconductor Solutions"
+ organization_url: "https://www.sony-semicon.com"
+ project: "AITRIOS Robotics Package"
+ project_url: "https://www.aitrios.sony-semicon.com/robotics"
+ domain:
+ - Logistics/Warehouse
+ - Manufacturing
+ - Service Robot
+ date_added: "2026-03-25"
+ country:
+ - JP
+ description: "Development platform for autonomous mobile robots across logistics, construction, and retail."
diff --git a/source/_static/adopters.css b/source/_static/adopters.css
new file mode 100644
index 00000000000..d67975955e3
--- /dev/null
+++ b/source/_static/adopters.css
@@ -0,0 +1,231 @@
+/* ROS 2 Adopters page styles */
+
+/* ===== Filter controls ===== */
+
+.adopters-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ padding: 1rem;
+ background: #f8f9fa;
+ border: 1px solid #dee2e6;
+ border-radius: 4px;
+}
+
+.adopters-filters label {
+ font-weight: bold;
+ margin-right: 0.25rem;
+}
+
+.adopters-filters select,
+.adopters-filters input[type="text"] {
+ padding: 0.35rem 0.5rem;
+ border: 1px solid #ced4da;
+ border-radius: 3px;
+ font-size: 0.9rem;
+}
+
+.adopters-filters input[type="text"] {
+ min-width: 180px;
+}
+
+.adopters-toggle-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-weight: bold;
+ cursor: pointer;
+ margin-left: auto;
+}
+
+.adopters-filter-note {
+ width: 100%;
+ font-size: 0.85rem;
+ color: #6c757d;
+ font-style: italic;
+}
+
+/* ===== Adopters table ===== */
+
+.adopters-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 2rem;
+}
+
+.adopters-table th,
+.adopters-table td {
+ padding: 0.6rem 0.8rem;
+ border: 1px solid #dee2e6;
+ text-align: left;
+ vertical-align: top;
+}
+
+.adopters-table thead th {
+ background: #2980b9;
+ color: #fff;
+ font-weight: bold;
+ position: sticky;
+ top: 0;
+}
+
+.adopters-table tbody tr:nth-child(even) {
+ background: #f8f9fa;
+}
+
+.adopters-table tbody tr:hover {
+ background: #e9ecef;
+}
+
+/* ===== YAML Generator Form ===== */
+
+.adopters-form-container {
+ max-width: 700px;
+}
+
+.adopters-form-container .form-group {
+ margin-bottom: 1rem;
+}
+
+.adopters-form-container label {
+ display: block;
+ font-weight: bold;
+ margin-bottom: 0.3rem;
+}
+
+.adopters-form-container .form-hint {
+ display: block;
+ font-size: 0.85rem;
+ color: #6c757d;
+ margin-bottom: 0.3rem;
+}
+
+.adopters-form-container input[type="text"],
+.adopters-form-container input[type="url"],
+.adopters-form-container select,
+.adopters-form-container textarea {
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid #ced4da;
+ border-radius: 3px;
+ font-size: 0.95rem;
+ box-sizing: border-box;
+}
+
+.adopters-form-container textarea {
+ min-height: 80px;
+ resize: vertical;
+}
+
+.adopters-form-container .domain-checkboxes {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem 1rem;
+}
+
+.adopters-form-container .domain-checkboxes label {
+ display: inline-flex;
+ align-items: center;
+ font-weight: normal;
+ gap: 0.3rem;
+}
+
+.adopters-form-container .btn {
+ display: inline-block;
+ padding: 0.6rem 1.2rem;
+ font-size: 0.95rem;
+ font-weight: bold;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ margin-right: 0.5rem;
+ margin-top: 0.5rem;
+}
+
+.adopters-form-container .btn-primary {
+ background: #2980b9;
+ color: #fff;
+}
+
+.adopters-form-container .btn-primary:hover {
+ background: #2471a3;
+}
+
+.adopters-form-container .btn-secondary {
+ background: #6c757d;
+ color: #fff;
+}
+
+.adopters-form-container .btn-secondary:hover {
+ background: #5a6268;
+}
+
+.adopters-form-container .btn-success {
+ background: #27ae60;
+ color: #fff;
+}
+
+.adopters-form-container .btn-success:hover {
+ background: #229954;
+}
+
+.adopters-country-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin-top: 0.4rem;
+}
+
+.adopters-country-tag {
+ display: inline-flex;
+ align-items: center;
+ background: #e9ecef;
+ border: 1px solid #ced4da;
+ border-radius: 3px;
+ padding: 0.2rem 0.5rem;
+ font-size: 0.85rem;
+}
+
+.adopters-country-tag-remove {
+ background: none;
+ border: none;
+ color: #721c24;
+ font-size: 1rem;
+ cursor: pointer;
+ padding: 0 0.2rem;
+ margin-left: 0.2rem;
+ line-height: 1;
+}
+
+.adopters-country-tag-remove:hover {
+ color: #c0392b;
+}
+
+#adopters-form-errors {
+ background: #f8d7da;
+ border: 1px solid #f5c6cb;
+ color: #721c24;
+ padding: 0.75rem 1rem;
+ border-radius: 4px;
+ margin-bottom: 1rem;
+}
+
+#adopters-form-errors ul {
+ margin: 0;
+ padding-left: 1.2rem;
+}
+
+#adopters-yaml-output {
+ background: #2d2d2d;
+ color: #f8f8f2;
+ padding: 1rem;
+ border-radius: 4px;
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+ font-size: 0.9rem;
+ white-space: pre;
+ overflow-x: auto;
+ margin-top: 1rem;
+ margin-bottom: 0.5rem;
+}
diff --git a/source/_static/adopters.js b/source/_static/adopters.js
new file mode 100644
index 00000000000..18850ff8cab
--- /dev/null
+++ b/source/_static/adopters.js
@@ -0,0 +1,358 @@
+/**
+ * ROS 2 Adopters - Table filtering and YAML generator.
+ *
+ * Copyright 2026 Sony Group Corporation.
+ * Licensed under the Apache License, Version 2.0.
+ *
+ * NOTE: The VALID_DOMAINS array below must be kept in sync
+ * with the corresponding constants in plugins/adopters_schema.py.
+ * Any additions or changes must be applied to both files.
+ */
+
+/* ===== ISO 3166-1 alpha-2 country codes ===== */
+
+var ISO_COUNTRIES = [
+ {code:'AD',name:'Andorra'},{code:'AE',name:'United Arab Emirates'},
+ {code:'AF',name:'Afghanistan'},{code:'AG',name:'Antigua and Barbuda'},
+ {code:'AL',name:'Albania'},{code:'AM',name:'Armenia'},
+ {code:'AO',name:'Angola'},{code:'AR',name:'Argentina'},
+ {code:'AT',name:'Austria'},{code:'AU',name:'Australia'},
+ {code:'AZ',name:'Azerbaijan'},{code:'BA',name:'Bosnia and Herzegovina'},
+ {code:'BB',name:'Barbados'},{code:'BD',name:'Bangladesh'},
+ {code:'BE',name:'Belgium'},{code:'BF',name:'Burkina Faso'},
+ {code:'BG',name:'Bulgaria'},{code:'BH',name:'Bahrain'},
+ {code:'BI',name:'Burundi'},{code:'BJ',name:'Benin'},
+ {code:'BN',name:'Brunei'},{code:'BO',name:'Bolivia'},
+ {code:'BR',name:'Brazil'},{code:'BS',name:'Bahamas'},
+ {code:'BT',name:'Bhutan'},{code:'BW',name:'Botswana'},
+ {code:'BY',name:'Belarus'},{code:'BZ',name:'Belize'},
+ {code:'CA',name:'Canada'},{code:'CD',name:'Congo (Democratic Republic)'},
+ {code:'CF',name:'Central African Republic'},{code:'CG',name:'Congo'},
+ {code:'CH',name:'Switzerland'},{code:'CI',name:"Cote d'Ivoire"},
+ {code:'CL',name:'Chile'},{code:'CM',name:'Cameroon'},
+ {code:'CN',name:'China'},{code:'CO',name:'Colombia'},
+ {code:'CR',name:'Costa Rica'},{code:'CU',name:'Cuba'},
+ {code:'CV',name:'Cape Verde'},{code:'CY',name:'Cyprus'},
+ {code:'CZ',name:'Czechia'},{code:'DE',name:'Germany'},
+ {code:'DJ',name:'Djibouti'},{code:'DK',name:'Denmark'},
+ {code:'DM',name:'Dominica'},{code:'DO',name:'Dominican Republic'},
+ {code:'DZ',name:'Algeria'},{code:'EC',name:'Ecuador'},
+ {code:'EE',name:'Estonia'},{code:'EG',name:'Egypt'},
+ {code:'ER',name:'Eritrea'},{code:'ES',name:'Spain'},
+ {code:'ET',name:'Ethiopia'},{code:'FI',name:'Finland'},
+ {code:'FJ',name:'Fiji'},{code:'FR',name:'France'},
+ {code:'GA',name:'Gabon'},{code:'GB',name:'United Kingdom'},
+ {code:'GD',name:'Grenada'},{code:'GE',name:'Georgia'},
+ {code:'GH',name:'Ghana'},{code:'GM',name:'Gambia'},
+ {code:'GN',name:'Guinea'},{code:'GQ',name:'Equatorial Guinea'},
+ {code:'GR',name:'Greece'},{code:'GT',name:'Guatemala'},
+ {code:'GW',name:'Guinea-Bissau'},{code:'GY',name:'Guyana'},
+ {code:'HK',name:'Hong Kong'},{code:'HN',name:'Honduras'},
+ {code:'HR',name:'Croatia'},{code:'HT',name:'Haiti'},
+ {code:'HU',name:'Hungary'},{code:'ID',name:'Indonesia'},
+ {code:'IE',name:'Ireland'},{code:'IL',name:'Israel'},
+ {code:'IN',name:'India'},{code:'IQ',name:'Iraq'},
+ {code:'IR',name:'Iran'},{code:'IS',name:'Iceland'},
+ {code:'IT',name:'Italy'},{code:'JM',name:'Jamaica'},
+ {code:'JO',name:'Jordan'},{code:'JP',name:'Japan'},
+ {code:'KE',name:'Kenya'},{code:'KG',name:'Kyrgyzstan'},
+ {code:'KH',name:'Cambodia'},{code:'KI',name:'Kiribati'},
+ {code:'KM',name:'Comoros'},{code:'KN',name:'Saint Kitts and Nevis'},
+ {code:'KP',name:'North Korea'},{code:'KR',name:'South Korea'},
+ {code:'KW',name:'Kuwait'},{code:'KZ',name:'Kazakhstan'},
+ {code:'LA',name:'Laos'},{code:'LB',name:'Lebanon'},
+ {code:'LC',name:'Saint Lucia'},{code:'LI',name:'Liechtenstein'},
+ {code:'LK',name:'Sri Lanka'},{code:'LR',name:'Liberia'},
+ {code:'LS',name:'Lesotho'},{code:'LT',name:'Lithuania'},
+ {code:'LU',name:'Luxembourg'},{code:'LV',name:'Latvia'},
+ {code:'LY',name:'Libya'},{code:'MA',name:'Morocco'},
+ {code:'MC',name:'Monaco'},{code:'MD',name:'Moldova'},
+ {code:'ME',name:'Montenegro'},{code:'MG',name:'Madagascar'},
+ {code:'MK',name:'North Macedonia'},{code:'ML',name:'Mali'},
+ {code:'MM',name:'Myanmar'},{code:'MN',name:'Mongolia'},
+ {code:'MR',name:'Mauritania'},{code:'MT',name:'Malta'},
+ {code:'MU',name:'Mauritius'},{code:'MV',name:'Maldives'},
+ {code:'MW',name:'Malawi'},{code:'MX',name:'Mexico'},
+ {code:'MY',name:'Malaysia'},{code:'MZ',name:'Mozambique'},
+ {code:'NA',name:'Namibia'},{code:'NE',name:'Niger'},
+ {code:'NG',name:'Nigeria'},{code:'NI',name:'Nicaragua'},
+ {code:'NL',name:'Netherlands'},{code:'NO',name:'Norway'},
+ {code:'NP',name:'Nepal'},{code:'NR',name:'Nauru'},
+ {code:'NZ',name:'New Zealand'},{code:'OM',name:'Oman'},
+ {code:'PA',name:'Panama'},{code:'PE',name:'Peru'},
+ {code:'PG',name:'Papua New Guinea'},{code:'PH',name:'Philippines'},
+ {code:'PK',name:'Pakistan'},{code:'PL',name:'Poland'},
+ {code:'PR',name:'Puerto Rico'},{code:'PS',name:'Palestine'},
+ {code:'PT',name:'Portugal'},{code:'PW',name:'Palau'},
+ {code:'PY',name:'Paraguay'},{code:'QA',name:'Qatar'},
+ {code:'RO',name:'Romania'},{code:'RS',name:'Serbia'},
+ {code:'RU',name:'Russia'},{code:'RW',name:'Rwanda'},
+ {code:'SA',name:'Saudi Arabia'},{code:'SB',name:'Solomon Islands'},
+ {code:'SC',name:'Seychelles'},{code:'SD',name:'Sudan'},
+ {code:'SE',name:'Sweden'},{code:'SG',name:'Singapore'},
+ {code:'SI',name:'Slovenia'},{code:'SK',name:'Slovakia'},
+ {code:'SL',name:'Sierra Leone'},{code:'SM',name:'San Marino'},
+ {code:'SN',name:'Senegal'},{code:'SO',name:'Somalia'},
+ {code:'SR',name:'Suriname'},{code:'SS',name:'South Sudan'},
+ {code:'SV',name:'El Salvador'},{code:'SY',name:'Syria'},
+ {code:'SZ',name:'Eswatini'},{code:'TD',name:'Chad'},
+ {code:'TG',name:'Togo'},{code:'TH',name:'Thailand'},
+ {code:'TJ',name:'Tajikistan'},{code:'TL',name:'Timor-Leste'},
+ {code:'TM',name:'Turkmenistan'},{code:'TN',name:'Tunisia'},
+ {code:'TO',name:'Tonga'},{code:'TR',name:'Turkey'},
+ {code:'TT',name:'Trinidad and Tobago'},{code:'TV',name:'Tuvalu'},
+ {code:'TW',name:'Taiwan'},{code:'TZ',name:'Tanzania'},
+ {code:'UA',name:'Ukraine'},{code:'UG',name:'Uganda'},
+ {code:'US',name:'United States'},{code:'UY',name:'Uruguay'},
+ {code:'UZ',name:'Uzbekistan'},{code:'VC',name:'Saint Vincent and the Grenadines'},
+ {code:'VE',name:'Venezuela'},{code:'VN',name:'Vietnam'},
+ {code:'VU',name:'Vanuatu'},{code:'WS',name:'Samoa'},
+ {code:'YE',name:'Yemen'},{code:'ZA',name:'South Africa'},
+ {code:'ZM',name:'Zambia'},{code:'ZW',name:'Zimbabwe'}
+];
+
+/* ===== Table filtering ===== */
+
+function initAdoptersTableFilters() {
+ var filterDomain = document.getElementById('adopters-filter-domain');
+ var filterCountry = document.getElementById('adopters-filter-country');
+ var filterSearch = document.getElementById('adopters-filter-search');
+ var showAllToggle = document.getElementById('adopters-show-all');
+
+ if (!filterDomain) return; // Not on the adopters table page.
+
+ // Compute the cutoff date (3 years ago) as YYYY-MM-DD string.
+ var now = new Date();
+ var cutoffYear = now.getFullYear() - 3;
+ var cutoffMonth = String(now.getMonth() + 1).padStart(2, '0');
+ var cutoffDay = String(now.getDate()).padStart(2, '0');
+ var cutoffDate = cutoffYear + '-' + cutoffMonth + '-' + cutoffDay;
+
+ function applyFilters() {
+ var domain = filterDomain.value;
+ var country = filterCountry.value;
+ var search = filterSearch.value.toLowerCase();
+ var showAll = showAllToggle.checked;
+
+ var rows = document.querySelectorAll('.adopters-table tbody tr');
+ rows.forEach(function(row) {
+ var domainMatch = !domain || row.getAttribute('data-domains').indexOf(domain) !== -1;
+ var countryMatch = !country || row.getAttribute('data-countries').indexOf(country) !== -1;
+ var searchMatch = !search || row.textContent.toLowerCase().indexOf(search) !== -1;
+
+ // Date-based filtering: hide entries older than 3 years unless toggle is on.
+ var dateAdded = row.getAttribute('data-date-added') || '';
+ var withinWindow = showAll || dateAdded >= cutoffDate;
+
+ row.style.display = (domainMatch && countryMatch && searchMatch && withinWindow) ? '' : 'none';
+ });
+ }
+
+ filterDomain.addEventListener('change', applyFilters);
+ filterCountry.addEventListener('change', applyFilters);
+ filterSearch.addEventListener('input', applyFilters);
+ showAllToggle.addEventListener('change', applyFilters);
+
+ // Apply initial filter to hide old entries by default.
+ applyFilters();
+}
+
+/* ===== YAML Generator Form ===== */
+
+function initAdoptersForm() {
+ var form = document.getElementById('adopters-yaml-form');
+ if (!form) return; // Not on the form page.
+
+ var generateBtn = document.getElementById('adopters-generate-btn');
+ var openPrBtn = document.getElementById('adopters-open-pr-btn');
+ var copyBtn = document.getElementById('adopters-copy-btn');
+ var output = document.getElementById('adopters-yaml-output');
+ var errorDiv = document.getElementById('adopters-form-errors');
+
+ // Auto-populate date_added with current YYYY-MM-DD.
+ var dateField = document.getElementById('field-date-added');
+ if (dateField) {
+ var now = new Date();
+ var yyyy = now.getFullYear();
+ var mm = String(now.getMonth() + 1).padStart(2, '0');
+ var dd = String(now.getDate()).padStart(2, '0');
+ dateField.value = yyyy + '-' + mm + '-' + dd;
+ }
+
+ // Populate country dropdown from ISO_COUNTRIES list.
+ var countrySelect = document.getElementById('field-country');
+ var addCountryBtn = document.getElementById('adopters-add-country-btn');
+ var selectedCountriesDiv = document.getElementById('adopters-selected-countries');
+ var selectedCountries = [];
+
+ if (countrySelect) {
+ ISO_COUNTRIES.forEach(function(c) {
+ var opt = document.createElement('option');
+ opt.value = c.code;
+ opt.textContent = c.name + ' (' + c.code + ')';
+ countrySelect.appendChild(opt);
+ });
+ }
+
+ function renderCountryTags() {
+ selectedCountriesDiv.innerHTML = '';
+ selectedCountries.forEach(function(code) {
+ var tag = document.createElement('span');
+ tag.className = 'adopters-country-tag';
+ var entry = ISO_COUNTRIES.find(function(c) { return c.code === code; });
+ var label = entry ? entry.name + ' (' + code + ')' : code;
+ tag.textContent = label + ' ';
+ var removeBtn = document.createElement('button');
+ removeBtn.type = 'button';
+ removeBtn.className = 'adopters-country-tag-remove';
+ removeBtn.textContent = '\u00d7';
+ removeBtn.addEventListener('click', function() {
+ selectedCountries = selectedCountries.filter(function(c) { return c !== code; });
+ renderCountryTags();
+ });
+ tag.appendChild(removeBtn);
+ selectedCountriesDiv.appendChild(tag);
+ });
+ }
+
+ if (addCountryBtn) {
+ addCountryBtn.addEventListener('click', function(e) {
+ e.preventDefault();
+ var code = countrySelect.value;
+ if (code && selectedCountries.indexOf(code) === -1) {
+ selectedCountries.push(code);
+ renderCountryTags();
+ }
+ countrySelect.value = '';
+ });
+ }
+
+ function getFormValues() {
+ var domains = [];
+ var checkboxes = form.querySelectorAll('input[name="domain"]:checked');
+ checkboxes.forEach(function(cb) { domains.push(cb.value); });
+
+ return {
+ organization: form.querySelector('#field-organization').value.trim(),
+ organization_url: form.querySelector('#field-organization-url').value.trim(),
+ project: form.querySelector('#field-project').value.trim(),
+ project_url: form.querySelector('#field-project-url').value.trim(),
+ domain: domains,
+ date_added: form.querySelector('#field-date-added').value.trim(),
+ country: selectedCountries.slice(),
+ description: form.querySelector('#field-description').value.trim()
+ };
+ }
+
+ function validateForm(values) {
+ var errors = [];
+ if (!values.organization) errors.push('Organization is required.');
+ if (!values.project) errors.push('Project is required.');
+ if (values.domain.length === 0) errors.push('At least one domain must be selected.');
+ if (!values.date_added) errors.push('Date added is required.');
+ if (values.date_added && !/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(values.date_added)) {
+ errors.push('Date added must be in YYYY-MM-DD format.');
+ }
+ if (values.country.length === 0) errors.push('At least one country must be selected.');
+ if (!values.description) errors.push('Description is required.');
+ if (values.organization_url && !isValidUrl(values.organization_url)) {
+ errors.push('Organization URL is not a valid URL.');
+ }
+ if (values.project_url && !isValidUrl(values.project_url)) {
+ errors.push('Project URL is not a valid URL.');
+ }
+ return errors;
+ }
+
+ function isValidUrl(str) {
+ try { new URL(str); return true; } catch (e) { return false; }
+ }
+
+ function yamlEscape(str) {
+ // Quote strings that contain YAML-special characters.
+ if (/[:#\[\]{}&*!|>'"%@`,?]/.test(str) || str !== str.trim()) {
+ return '"' + str.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
+ }
+ return '"' + str + '"';
+ }
+
+ function generateYaml(values) {
+ var lines = [];
+ lines.push(' - organization: ' + yamlEscape(values.organization));
+ if (values.organization_url) {
+ lines.push(' organization_url: ' + yamlEscape(values.organization_url));
+ }
+ lines.push(' project: ' + yamlEscape(values.project));
+ if (values.project_url) {
+ lines.push(' project_url: ' + yamlEscape(values.project_url));
+ }
+ lines.push(' domain:');
+ values.domain.forEach(function(d) {
+ lines.push(' - ' + d);
+ });
+ lines.push(' date_added: ' + yamlEscape(values.date_added));
+ lines.push(' country:');
+ values.country.forEach(function(c) {
+ lines.push(' - ' + c);
+ });
+ lines.push(' description: ' + yamlEscape(values.description));
+ return lines.join('\n');
+ }
+
+ generateBtn.addEventListener('click', function(e) {
+ e.preventDefault();
+ var values = getFormValues();
+ var errors = validateForm(values);
+
+ if (errors.length > 0) {
+ errorDiv.innerHTML = '
' + errors.map(function(e) {
+ return '
' + e + '
';
+ }).join('') + '
';
+ errorDiv.style.display = 'block';
+ output.style.display = 'none';
+ openPrBtn.style.display = 'none';
+ copyBtn.style.display = 'none';
+ return;
+ }
+
+ errorDiv.style.display = 'none';
+ var yamlStr = generateYaml(values);
+ output.textContent = yamlStr;
+ output.style.display = 'block';
+ openPrBtn.style.display = 'inline-block';
+ copyBtn.style.display = 'inline-block';
+ });
+
+ copyBtn.addEventListener('click', function(e) {
+ e.preventDefault();
+ var text = output.textContent;
+ navigator.clipboard.writeText(text).then(function() {
+ var origText = copyBtn.textContent;
+ copyBtn.textContent = 'Copied!';
+ setTimeout(function() { copyBtn.textContent = origText; }, 2000);
+ });
+ });
+
+ openPrBtn.addEventListener('click', function(e) {
+ e.preventDefault();
+ var yamlSnippet = output.textContent;
+ // GitHub web editor URL — opens adopters.yaml for editing on rolling branch.
+ var editUrl = 'https://github.com/ros2/ros2_documentation/edit/rolling/'
+ + 'source/The-ROS2-Project/Adopters/adopters.yaml';
+ // We cannot pre-fill the edit content via URL, but we copy the YAML to clipboard
+ // and open the editor so the user can paste.
+ navigator.clipboard.writeText(yamlSnippet).then(function() {
+ window.open(editUrl, '_blank');
+ }).catch(function() {
+ // Fallback if clipboard fails.
+ window.open(editUrl, '_blank');
+ });
+ });
+}
+
+/* ===== Init on page load ===== */
+
+document.addEventListener('DOMContentLoaded', function() {
+ initAdoptersTableFilters();
+ initAdoptersForm();
+});
diff --git a/source/index.rst b/source/index.rst
index 06ed8ef68a4..884e4adb1c7 100644
--- a/source/index.rst
+++ b/source/index.rst
@@ -101,6 +101,10 @@ If you're interested in the advancement of the ROS 2 project:
- Downloadable marketing materials
- `Information about the ROS trademark `__
+* :doc:`Adopters `
+
+ - Organizations and projects using ROS
+
ROS community resources
-----------------------
diff --git a/test/test_adopters.py b/test/test_adopters.py
new file mode 100644
index 00000000000..485b7ba71b0
--- /dev/null
+++ b/test/test_adopters.py
@@ -0,0 +1,207 @@
+# Copyright 2026 Sony Group Corporation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for adopters YAML validation."""
+
+import os
+import sys
+
+import yaml
+
+# Make the plugins directory importable.
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'plugins'))
+
+from adopters_schema import VALID_DOMAINS, validate_adopters
+
+
+def _valid_entry(**overrides):
+ """Return a minimal valid adopter entry, with optional overrides."""
+ entry = {
+ 'organization': 'Test Org',
+ 'project': 'Test Project',
+ 'domain': ['Research'],
+ 'date_added': '2026-03-25',
+ 'country': ['US'],
+ 'description': 'A test project.',
+ }
+ entry.update(overrides)
+ return entry
+
+
+class TestValidateAdopters:
+
+ def test_valid_entry(self):
+ assert validate_adopters([_valid_entry()]) == []
+
+ def test_valid_entry_with_optional_fields(self):
+ entry = _valid_entry(
+ organization_url='https://example.com',
+ project_url='https://example.com/project',
+ )
+ assert validate_adopters([entry]) == []
+
+ def test_multiple_domains(self):
+ entry = _valid_entry(domain=['Research', 'Education'])
+ assert validate_adopters([entry]) == []
+
+ def test_multiple_countries(self):
+ entry = _valid_entry(country=['US', 'JP', 'DE'])
+ assert validate_adopters([entry]) == []
+
+ def test_missing_organization(self):
+ entry = _valid_entry()
+ del entry['organization']
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'organization' in errors[0]
+
+ def test_missing_project(self):
+ entry = _valid_entry()
+ del entry['project']
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'project' in errors[0]
+
+ def test_missing_domain(self):
+ entry = _valid_entry()
+ del entry['domain']
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'domain' in errors[0]
+
+ def test_missing_date_added(self):
+ entry = _valid_entry()
+ del entry['date_added']
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'date_added' in errors[0]
+
+ def test_missing_country(self):
+ entry = _valid_entry()
+ del entry['country']
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'country' in errors[0]
+
+ def test_missing_description(self):
+ entry = _valid_entry()
+ del entry['description']
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'description' in errors[0]
+
+ def test_invalid_domain(self):
+ entry = _valid_entry(domain=['InvalidDomain'])
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'InvalidDomain' in errors[0]
+
+ def test_invalid_date_added_year_only(self):
+ entry = _valid_entry(date_added='2026')
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'YYYY-MM-DD' in errors[0]
+
+ def test_invalid_date_added_month_only(self):
+ entry = _valid_entry(date_added='2026-03')
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'YYYY-MM-DD' in errors[0]
+
+ def test_invalid_date_added_bad_month(self):
+ entry = _valid_entry(date_added='2026-13-01')
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'YYYY-MM-DD' in errors[0]
+
+ def test_invalid_date_added_bad_month_zero(self):
+ entry = _valid_entry(date_added='2026-00-15')
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'YYYY-MM-DD' in errors[0]
+
+ def test_invalid_date_added_bad_day_zero(self):
+ entry = _valid_entry(date_added='2026-03-00')
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'YYYY-MM-DD' in errors[0]
+
+ def test_invalid_date_added_bad_day_32(self):
+ entry = _valid_entry(date_added='2026-03-32')
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'YYYY-MM-DD' in errors[0]
+
+ def test_valid_date_added_formats(self):
+ for date in ['2020-01-01', '2026-12-31', '1999-06-15']:
+ entry = _valid_entry(date_added=date)
+ assert validate_adopters([entry]) == [], f'Date {date} should be valid'
+
+ def test_country_not_a_list(self):
+ entry = _valid_entry(country='US')
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'list' in errors[0]
+
+ def test_invalid_country_too_long(self):
+ entry = _valid_entry(country=['USA'])
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'country' in errors[0]
+
+ def test_invalid_country_numeric(self):
+ entry = _valid_entry(country=['12'])
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'country' in errors[0]
+
+ def test_invalid_country_in_list(self):
+ entry = _valid_entry(country=['US', 'INVALID', 'JP'])
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'INVALID' in errors[0]
+
+ def test_domain_not_a_list(self):
+ entry = _valid_entry(domain='Research')
+ errors = validate_adopters([entry])
+ assert len(errors) == 1
+ assert 'list' in errors[0]
+
+ def test_empty_list(self):
+ assert validate_adopters([]) == []
+
+ def test_not_a_list(self):
+ errors = validate_adopters('not a list')
+ assert len(errors) == 1
+ assert 'must be a list' in errors[0]
+
+ def test_all_valid_domains_accepted(self):
+ for domain in VALID_DOMAINS:
+ entry = _valid_entry(domain=[domain])
+ assert validate_adopters([entry]) == [], f'Domain {domain} should be valid'
+
+
+class TestAdoptersYamlFile:
+ """Validate the actual adopters.yaml file."""
+
+ def test_adopters_yaml_is_valid(self):
+ yaml_path = os.path.join(
+ os.path.dirname(__file__), '..', 'source',
+ 'The-ROS2-Project', 'Adopters', 'adopters.yaml',
+ )
+ with open(yaml_path) as f:
+ data = yaml.safe_load(f)
+ adopters = data.get('adopters', [])
+ errors = validate_adopters(adopters)
+ assert errors == [], f'Validation errors:\n' + '\n'.join(errors)