Skip to content

Commit b03d65f

Browse files
ci: Divide checkers to docs and samples
- One workflow will check documentation and the other one samples. Signed-off-by: Arkadiusz Balys <arkadiusz.balys@nordicsemi.no>
1 parent 1ea7c8b commit b03d65f

6 files changed

Lines changed: 412 additions & 150 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
name: Matter Documentation Validation
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- main
7+
- 'v*-branch'
8+
9+
paths:
10+
- 'docs/**'
11+
- 'samples/**/*.rst'
12+
- 'scripts/matter_sample_checker/**'
13+
- '.github/workflows/doc-validation.yml'
14+
- 'west.yml'
15+
16+
permissions:
17+
contents: read
18+
19+
env:
20+
WEST_PATH_CACHE: ${{ github.workspace }}/west-path-cache
21+
22+
jobs:
23+
matter-doc-validation:
24+
runs-on: ubuntu-24.04
25+
steps:
26+
- name: Checkout the code
27+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
28+
with:
29+
path: ncs-matter
30+
31+
- name: Set up Python
32+
uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5
33+
with:
34+
python-version: 3.12
35+
cache: pip
36+
cache-dependency-path: |
37+
ncs-matter/scripts/requirements.txt
38+
ncs-matter/docs/requirements-doc.txt
39+
40+
- name: Install packages
41+
run: |
42+
sudo apt-get update
43+
sudo apt-get install -y wget python3-pip git
44+
pip install -r ncs-matter/scripts/requirements.txt
45+
pip install -r ncs-matter/docs/requirements-doc.txt
46+
47+
- name: Restore west module path cache
48+
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4
49+
with:
50+
path: west-path-cache
51+
key: west-path-cache-${{ hashFiles('ncs-matter/west.yml') }}
52+
restore-keys: |
53+
west-path-cache-
54+
55+
- name: Prepare west workspace
56+
run: |
57+
rm -rf .west
58+
west init -l ncs-matter --mf west.yml
59+
west update -n -o=--depth=1 --path-cache "${WEST_PATH_CACHE}"
60+
61+
- name: Validate Matter documentation
62+
run: |
63+
python ncs-matter/scripts/matter_sample_checker/matter_doc_checker.py \
64+
--base ncs-matter

‎.github/workflows/sample-validation.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ jobs:
5656
west init -l ncs-matter --mf west.yml
5757
west update -n -o=--depth=1 --path-cache "${WEST_PATH_CACHE}"
5858
59-
- name: validate Matter samples and documentation
59+
- name: Validate Matter samples
6060
run: |
6161
python ncs-matter/scripts/matter_sample_checker/matter_sample_checker.py \
6262
--samples-zap-yaml ncs-matter/scripts/zap_samples.yml \

‎scripts/matter_sample_checker/checks/docs/check_doc_links.py‎

Lines changed: 143 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,18 @@
5050
"[ref.envvar]",
5151
)
5252
LINKCHECK_BROKEN_RE = re.compile(
53-
r"^(?P<line>\d+) \[(?P<status>broken|redirected|ignored).*?\] "
54-
r"(?P<uri>\S+?)(?: to .*?)?$"
53+
r"^(?:"
54+
r"(?P<source>.+?):(?P<line>\d+):\s*"
55+
r"|(?P<output_line>\d+)\s+"
56+
r")\[(?P<status>[^\]]+)\]\s+"
57+
r"(?P<uri>\S+)"
58+
)
59+
LINKCHECK_OUTPUT_LINE_RE = re.compile(
60+
r"^(?:"
61+
r"(?P<source>.+?):(?P<line>\d+):\s*"
62+
r"|(?P<output_line>\d+)\s+"
63+
r")\[(?P<status>[^\]]+)\]\s+"
64+
r"(?P<uri>\S+)(?P<detail>.*)$"
5565
)
5666
SPHINX_PERCENT_RE = re.compile(r"\[\s*(\d+)%\]\s*(.*)$")
5767
SPHINX_PHASE_RE = re.compile(
@@ -114,6 +124,119 @@ def sphinx_line(self, line: str) -> None:
114124
def reset_percent(self) -> None:
115125
self._last_percent = -1
116126

127+
def replay_log(self, heading: str, lines: Sequence[str]) -> None:
128+
if self.quiet or not lines:
129+
return
130+
self.info(heading)
131+
for line in lines:
132+
self.info(f" {line}")
133+
134+
135+
def _read_text(path: Path) -> str:
136+
if not path.is_file():
137+
return ""
138+
return path.read_text(encoding="utf-8", errors="replace")
139+
140+
141+
def _sphinx_log_lines(log_text: str) -> list[str]:
142+
"""Extract warning/error lines from a Sphinx build or warning log."""
143+
lines: list[str] = []
144+
seen: set[str] = set()
145+
146+
for raw_line in log_text.splitlines():
147+
stripped = raw_line.strip()
148+
if not stripped or stripped.startswith("$ ") or stripped.startswith("exit_code:"):
149+
continue
150+
if SPHINX_PERCENT_RE.search(stripped) or SPHINX_PHASE_RE.search(stripped):
151+
continue
152+
153+
normalized = stripped
154+
match = SPHINX_ISSUE_RE.match(raw_line)
155+
if match:
156+
normalized = (
157+
f"{match.group('file')}:{match.group('line')}: "
158+
f"{match.group('level')}: {match.group('message')}"
159+
)
160+
elif stripped.startswith("WARNING: ") or stripped.startswith("ERROR: "):
161+
normalized = stripped
162+
elif " WARNING:" not in raw_line and " ERROR:" not in raw_line:
163+
continue
164+
165+
if normalized not in seen:
166+
seen.add(normalized)
167+
lines.append(normalized)
168+
169+
return lines
170+
171+
172+
def _linkcheck_output_lines(output_text: str) -> tuple[list[str], list[str]]:
173+
"""Split linkcheck output.txt lines into info (broken) and debug (other) buckets."""
174+
info_lines: list[str] = []
175+
debug_lines: list[str] = []
176+
seen: set[str] = set()
177+
178+
for raw_line in output_text.splitlines():
179+
stripped = raw_line.strip()
180+
if not stripped or stripped in seen:
181+
continue
182+
seen.add(stripped)
183+
184+
match = LINKCHECK_OUTPUT_LINE_RE.match(stripped)
185+
if match:
186+
status = match.group("status")
187+
uri = match.group("uri")
188+
detail = (match.group("detail") or "").rstrip()
189+
source = match.group("source")
190+
line_no = match.group("line") or match.group("output_line")
191+
location = f"{source}:{line_no}" if source and line_no else f"linkcheck:{line_no or '?'}"
192+
suffix = detail if not detail or detail.startswith((" ", "\t")) else f" {detail}"
193+
message = f"{location}: [{status}] {uri}{suffix}"
194+
if status.startswith("broken"):
195+
info_lines.append(message)
196+
else:
197+
debug_lines.append(message)
198+
continue
199+
200+
if "[broken" in stripped.lower():
201+
info_lines.append(stripped)
202+
elif "[redirected" in stripped.lower() or "[ignored" in stripped.lower():
203+
debug_lines.append(stripped)
204+
else:
205+
debug_lines.append(stripped)
206+
207+
return info_lines, debug_lines
208+
209+
210+
def _emit_sphinx_build_log(progress: ProgressLog, log_path: Path, *, heading: str) -> None:
211+
lines = _sphinx_log_lines(_read_text(log_path))
212+
if lines:
213+
progress.replay_log(heading, lines)
214+
elif not progress.quiet:
215+
progress.info(f"{heading}: no warnings or errors recorded")
216+
217+
218+
def _emit_linkcheck_logs(
219+
progress: ProgressLog,
220+
log_path: Path,
221+
output_path: Path,
222+
) -> None:
223+
broken_lines, other_lines = _linkcheck_output_lines(_read_text(output_path))
224+
warning_lines = _sphinx_log_lines(_read_text(log_path))
225+
226+
if broken_lines:
227+
progress.replay_log("External linkcheck broken URLs:", broken_lines)
228+
elif not progress.quiet:
229+
progress.info("External linkcheck broken URLs: none")
230+
231+
if other_lines:
232+
progress.info("External linkcheck other results:")
233+
for line in other_lines:
234+
progress.debug(f" {line}")
235+
236+
extra_warnings = [line for line in warning_lines if line not in broken_lines]
237+
if extra_warnings:
238+
progress.replay_log("Sphinx linkcheck warnings:", extra_warnings)
239+
117240

118241
@dataclass(frozen=True)
119242
class DocLinksPaths:
@@ -317,14 +440,24 @@ def _parse_linkcheck_output(output_path: Path) -> list[LinkIssue]:
317440

318441
issues: list[LinkIssue] = []
319442
for raw_line in output_path.read_text(encoding="utf-8", errors="replace").splitlines():
320-
match = LINKCHECK_BROKEN_RE.match(raw_line)
321-
if not match or match.group("status") != "broken":
443+
match = LINKCHECK_BROKEN_RE.match(raw_line.strip())
444+
if not match or not match.group("status").startswith("broken"):
322445
continue
446+
447+
source = match.group("source")
448+
line_str = match.group("line") or match.group("output_line")
449+
if source and line_str:
450+
issue_source = f"{source}:{line_str}"
451+
issue_line = int(line_str)
452+
else:
453+
issue_source = str(output_path)
454+
issue_line = int(line_str) if line_str else None
455+
323456
issues.append(
324457
LinkIssue(
325458
check="external-link",
326-
source=str(output_path),
327-
line=int(match.group("line")),
459+
source=issue_source,
460+
line=issue_line,
328461
message="unreachable URL",
329462
target=match.group("uri"),
330463
)
@@ -429,9 +562,8 @@ def check_cross_references(
429562
)
430563
)
431564

432-
progress.info(
433-
f"Cross-reference check finished with {len(issues)} issue(s); log: {log_path}"
434-
)
565+
progress.info(f"Cross-reference check finished with {len(issues)} issue(s)")
566+
_emit_sphinx_build_log(progress, log_path, heading="Cross-reference Sphinx log")
435567
return issues, str(log_path)
436568

437569

@@ -469,9 +601,8 @@ def check_external_links(
469601
)
470602
)
471603

472-
progress.info(
473-
f"External link check finished with {len(issues)} broken URL(s); log: {log_path}"
474-
)
604+
progress.info(f"External link check finished with {len(issues)} broken URL(s)")
605+
_emit_linkcheck_logs(progress, log_path, output_txt)
475606
return issues, str(log_path), str(output_txt)
476607

477608

@@ -617,16 +748,6 @@ def check(self):
617748
self.warning(f"Optional documentation links check skipped: {exc}")
618749
return
619750

620-
if report.intersphinx_docsets:
621-
self.debug(f"Intersphinx docsets: {', '.join(report.intersphinx_docsets)}")
622-
else:
623-
self.debug(
624-
"No local NCS docset inventories found; only in-addon :ref: targets validated"
625-
)
626-
627-
for log_name, log_path in report.log_paths.items():
628-
self.debug(f"{log_name}: {log_path}")
629-
630751
for link_issue in report.issues:
631752
self.issue(_format_link_issue(link_issue))
632753

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#
2+
# Copyright (c) 2026 Nordic Semiconductor ASA
3+
#
4+
# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause
5+
6+
"""Shared CLI helpers for matter_sample_checker and matter_doc_checker."""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import os
12+
import sys
13+
from datetime import datetime
14+
from pathlib import Path
15+
16+
17+
def checker_script_dir() -> Path:
18+
return Path(__file__).resolve().parent.parent
19+
20+
21+
def default_config_path() -> Path:
22+
return checker_script_dir() / "matter_sample_checker_config.yaml"
23+
24+
25+
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
26+
parser.add_argument(
27+
"--base",
28+
"-b",
29+
type=str,
30+
help="Base directory for resolving workspace paths. If not specified, uses the "
31+
"ncs-matter repository root containing this script.",
32+
)
33+
parser.add_argument(
34+
"--verbose",
35+
"-v",
36+
action="store_true",
37+
help="Show verbose output during checks",
38+
)
39+
parser.add_argument(
40+
"--config",
41+
"-c",
42+
type=str,
43+
help="Path to custom configuration YAML file. Default: matter_sample_checker_config.yaml "
44+
"in the checker script directory.",
45+
)
46+
47+
48+
def resolve_workspace_base(base_arg: str | None) -> Path:
49+
if base_arg:
50+
return Path(base_arg).resolve()
51+
52+
script_repo_root = checker_script_dir().parent.parent
53+
if (script_repo_root / "west.yml").exists() and (script_repo_root / "samples").is_dir():
54+
return script_repo_root
55+
56+
zephyr_base = os.environ.get("ZEPHYR_BASE")
57+
if not zephyr_base:
58+
print(
59+
"Error: --base not specified and ncs-matter repository root could not be inferred.",
60+
file=sys.stderr,
61+
)
62+
sys.exit(1)
63+
64+
nrf_base = Path(zephyr_base).resolve().parent / "ncs-matter"
65+
if not nrf_base.is_dir():
66+
print(f"Error: Could not infer ncs-matter workspace base: {nrf_base}", file=sys.stderr)
67+
sys.exit(1)
68+
return nrf_base
69+
70+
71+
def resolve_config_path(config_arg: str | None) -> Path:
72+
return Path(config_arg) if config_arg else default_config_path()
73+
74+
75+
def parse_expected_years(year_arg: list[int] | None) -> list[int]:
76+
if year_arg is None:
77+
return []
78+
if len(year_arg) == 0:
79+
return [datetime.now().year]
80+
return year_arg

0 commit comments

Comments
 (0)