From 040ca1c89d7e8d9df7893a07524ce5fbae751df8 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Fri, 2 Oct 2026 14:41:44 +0200 Subject: [PATCH 1/2] ci: docs: build the documentation and publish it to GitHub Pages A workflow builds the zdocs document set (doc/: the manual and the API reference) and publishes it at https://tiacsys.github.io/zdocs/. A pull request builds and checks only. A push to main, or a manual run on main, also deploys. The site root is deploy/html/, as deploy-layout.rst and ADR 0012 describe, with a landing page next to the documents. This document set has no Doxygen document, so the Doxygen cross-document navigation is not in the site. New files: * .github/workflows/docs.yml: actions pinned by commit SHA, Doxygen 1.16.1 from the release binary, checked by hash and cached. Read-only permissions, except pages and id-token in the deploy job. * .github/scripts/check_site.py: every href and src of the assembled site must resolve to a file in the site, and a #fragment to an id. * doc/west.yml: the workspace for CI, Zephyr v4.4.1 only. The build needs Zephyr as a CMake package and for its doc/_extensions. * doc/requirements-ci.txt: every Python package pinned, also the indirect ones. sphinx-needs is 8.3.0, the reference version. Pygments stays at 2.20.0: with 2.21.0, doccheck reads a pattern in highlighted code as a dead link. * doc/site/index.html: the landing page. Gates after the build: doc-check, zero warnings in the stage-two logs, the link check, and an audit for local paths, addresses and tokens. Checked locally in a west workspace like the CI one, with a new venv from the lock: doc-check OK, 0 warnings, 71 pages and 3626 internal links with 0 broken, audit clean, and an HTTP crawl of the site under /zdocs/ with 0 failures. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- .github/scripts/check_site.py | 89 ++++++++++++++++ .github/workflows/docs.yml | 194 ++++++++++++++++++++++++++++++++++ doc/requirements-ci.txt | 77 ++++++++++++++ doc/site/index.html | 46 ++++++++ doc/west.yml | 29 +++++ 5 files changed, 435 insertions(+) create mode 100644 .github/scripts/check_site.py create mode 100644 .github/workflows/docs.yml create mode 100644 doc/requirements-ci.txt create mode 100644 doc/site/index.html create mode 100644 doc/west.yml diff --git a/.github/scripts/check_site.py b/.github/scripts/check_site.py new file mode 100644 index 0000000..4b9bb6c --- /dev/null +++ b/.github/scripts/check_site.py @@ -0,0 +1,89 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 +"""Check the internal links of the assembled Pages site. + +Usage: python check_site.py + +The site is served under a path prefix (https://tiacsys.github.io/zdocs/). +A static server maps a URL to a file, so the check resolves every href and +src of every HTML page against the file system, relative to the page: + +- the target file must exist (a directory needs an index.html), +- the target must stay inside the site directory (a link that climbs out + of it leaves the prefix and fails on GitHub Pages), +- a #fragment on an HTML target must name an id or a name in that page. + +A ?query is ignored. Links with a scheme (https:, mailto:, ...) and +protocol-relative links are not checked. The exit status is 1 if there is +a broken link. +""" + +import sys +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote, urldefrag, urlparse + + +class Page(HTMLParser): + def __init__(self): + super().__init__() + self.links, self.ids = [], set() + + def handle_starttag(self, tag, attrs): + a = dict(attrs) + for key in ("id", "name"): + if a.get(key): + self.ids.add(a[key]) + for key in ("href", "src"): + if a.get(key): + self.links.append(a[key]) + + +_PAGES = {} + + +def parse(path): + if path not in _PAGES: + page = Page() + page.feed(path.read_text(encoding="utf-8", errors="replace")) + _PAGES[path] = page + return _PAGES[path] + + +def main(root): + root = root.resolve() + pages = sorted(root.rglob("*.html")) + broken, checked = [], 0 + for page_path in pages: + for link in parse(page_path).links: + url = urlparse(link) + if url.scheme or url.netloc or link.startswith("#"): + continue + checked += 1 + target_ref, fragment = urldefrag(link) + target_ref = target_ref.split("?", 1)[0] + if target_ref.startswith("/"): + broken.append((page_path, link, "absolute path")) + continue + target = (page_path.parent / unquote(target_ref)).resolve() + if not target.is_relative_to(root): + broken.append((page_path, link, "outside the site")) + continue + if target.is_dir(): + target = target / "index.html" + if not target.is_file(): + broken.append((page_path, link, "no such file")) + continue + if fragment and target.suffix == ".html" and unquote(fragment) not in parse(target).ids: + broken.append((page_path, link, "no such #fragment")) + print(f"check_site: {len(pages)} pages, {checked} internal links, {len(broken)} broken") + for page_path, link, reason in broken: + print(f" {page_path.relative_to(root)}: {link} ({reason})") + return 1 if broken else 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit(__doc__) + sys.exit(main(Path(sys.argv[1]))) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..e361c60 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,194 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 +# +# Builds the documentation of zdocs itself (doc/: the manual and the API +# reference) and publishes it to GitHub Pages +# (https://tiacsys.github.io/zdocs/). +# +# - Pull request: build and checks only. +# - Push to main, or a manual run on main: build, checks, deploy. +# +# Not in this workflow: the engine unit tests, the zdocs-tests acceptance +# suite and twister runs. This document set has no PDF and no Doxygen +# document. +# +# One-time repository setting: Settings > Pages > Source = "GitHub Actions". + +name: Documentation + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + +env: + # zdocs runs find_package(Doxygen REQUIRED) at configure time, also for a + # document set without a Doxygen document. The job uses the same Doxygen + # as the consumers (>= 1.16 for native \verifies and \satisfies): the + # official release binary, checked by hash. Ubuntu has older packages. + DOXYGEN_VERSION: "1.16.1" + DOXYGEN_SHA256: "a56f885d37e3aae08a99f638d17bbb381224c03a878d9e2dda4f9fa4baf1d8bd" + +jobs: + build: + name: Build documentation + runs-on: ubuntu-24.04 + env: + # find_package(Zephyr) in doc/CMakeLists.txt finds Zephyr through + # ZEPHYR_BASE. West does not set it. + ZEPHYR_BASE: ${{ github.workspace }}/zephyr + steps: + # The west workspace is the job's work directory: this repository is + # the manifest repository at zdocs/, doc/west.yml adds zephyr/. + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: zdocs + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: zdocs/doc/requirements-ci.txt + + - name: Install Python packages + run: python -m pip install -r zdocs/doc/requirements-ci.txt + + # dot renders the graphviz diagrams of the architecture pages. + - name: Install Graphviz + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends graphviz + + - name: Restore Doxygen + id: doxygen-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/doxygen + key: doxygen-${{ env.DOXYGEN_VERSION }}-${{ env.DOXYGEN_SHA256 }} + + - name: Download Doxygen + if: steps.doxygen-cache.outputs.cache-hit != 'true' + run: | + tag="Release_${DOXYGEN_VERSION//./_}" + curl -sSfL -o doxygen.tar.gz \ + "https://github.com/doxygen/doxygen/releases/download/${tag}/doxygen-${DOXYGEN_VERSION}.linux.bin.tar.gz" + echo "${DOXYGEN_SHA256} doxygen.tar.gz" | sha256sum --check --strict + mkdir -p ~/doxygen + tar -xzf doxygen.tar.gz -C ~/doxygen --strip-components=1 + rm doxygen.tar.gz + + - name: Put Doxygen on the path + run: echo "${HOME}/doxygen/bin" >> "${GITHUB_PATH}" + + - name: Show tool versions + run: | + doxygen --version + dot -V + cmake --version | head -n 1 + python --version + west --version + python -m pip freeze + + # Zephyr is needed only as a CMake package (find_package(Zephyr + # COMPONENTS doc)) and for its doc/_extensions: no toolchain, no SDK, + # no module. A narrow, shallow update fetches Zephyr only, at depth 1. + - name: Set up the west workspace + run: | + west init -l --mf doc/west.yml zdocs + west update --narrow --fetch-opt=--depth=1 + west list + + - name: Configure + run: cmake -S zdocs/doc -B build/doc + + # Stage two of the build is all-or-nothing, so the stage-one indexes + # of every document come first. all-docs runs doc-check as its last + # step. + - name: Build the documents + run: | + cmake --build build/doc --target doc-index + cmake --build build/doc --target all-docs + + - name: Check cross-document links + run: cmake --build build/doc --target doc-check + + # The stage-two logs (html.log) hold only the warnings of the final + # build. The baseline is zero warnings. + - name: Check for warnings + run: | + if grep -nE 'WARNING|ERROR' build/doc/*/build/html.log; then + echo "::error::the documentation build has warnings" + exit 1 + fi + + # The site root is deploy/html/, as doc/manual/explanation/ + # deploy-layout.rst describes: the documents are at /. The + # landing page sits at the root, next to them. This document set has + # no Doxygen document, so the Doxygen cross-document navigation (it + # expects the deploy/ level as the root) is not in the site. + - name: Assemble the site + run: | + cp -a build/doc/deploy/html build/site + cp zdocs/doc/site/index.html build/site/index.html + + - name: Check the links of the site + run: python zdocs/.github/scripts/check_site.py build/site + + # Nothing private goes public: no local paths, no addresses, no tokens. + # The reference pages use jane@example.com and john@example.com as + # sample values, so example.com, .org and .net are accepted. + - name: Audit the site + working-directory: build/site + run: | + status=0 + refuse() { + if grep -rlIE -- "$2" .; then + echo "::error::the site contains $1" + status=1 + fi + } + refuse "a local absolute path" '/home/[^/ ]+/|/Users/|/tmp/|/wrk/' + refuse "a local server address" 'localhost:[0-9]|127\.0\.0\.1' + refuse "a token or a private key" 'ghp_|gho_|github_pat_|glpat-|AKIA[0-9A-Z]{16}|xox[baprs]-|-----BEGIN [A-Z ]*PRIVATE KEY' + if grep -rhoIE '[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}' . \ + | grep -vE '@example\.(com|org|net)$'; then + echo "::error::the site contains an e-mail address" + status=1 + fi + exit "${status}" + + - name: Upload the site + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: build/site + + deploy: + name: Deploy to GitHub Pages + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + concurrency: + group: pages + cancel-in-progress: false + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/doc/requirements-ci.txt b/doc/requirements-ci.txt new file mode 100644 index 0000000..4f9472d --- /dev/null +++ b/doc/requirements-ci.txt @@ -0,0 +1,77 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 +# +# Python packages for the documentation workflow +# (.github/workflows/docs.yml). Every package is pinned, also the indirect +# ones, so that a new release cannot change the CI build. +# +# pip install -r doc/requirements-ci.txt +# +# The engine versions are the ones in sphinx/requirements-doc.txt, with one +# exception: sphinx-needs is 8.3.0 here, the reference version, and +# sphinx/requirements-doc.txt still pins 8.1.1. The document-set version is +# the one in doc/requirements.txt. Change a version here only together with +# the same change in those files, and with a full docs build. sphinx-autobuild and sphobjinv are not here: the workflow does +# not use the html-live targets or `needs: source: inventory`. + +# Workspace and Zephyr module discovery (find_package(Zephyr) runs +# zephyr_module.py, which validates module.yml files with jsonschema). +west==1.4.0 +jsonschema==4.26.0 +PyYAML==6.0.3 + +# The zdocs engine (sphinx/requirements-doc.txt; sphinx-needs: see above). +Sphinx==9.1.0 +sphinx_rtd_theme==3.1.0 +sphinx-needs==8.3.0 +sphinxcontrib-doxylink==1.13.0 + +# This document set only (doc/requirements.txt). +sphinxcontrib-moderncmakedomain==3.29.0 + +# Pygments 2.21.0 writes quotes in highlighted code without escaping. Then +# doccheck reads the HREF_RE pattern in the viewcode page of doccheck.py as a +# dead link ("html/api/_modules/doccheck.html: dead link -> ([^"). Remove this +# pin when doccheck's HREF_RE ignores code (a separate engine fix). +Pygments==2.20.0 + +# Indirect packages (Python 3.12). Each version is the one that the zdocs +# test suites ran with, if that environment has the package. +alabaster==1.0.0 +attrs==26.1.0 +babel==2.18.0 +certifi==2026.7.22 +charset-normalizer==3.4.9 +colorama==0.4.6 +docopt==0.6.2 +docutils==0.22.4 +idna==3.18 +imagesize==2.0.0 +Jinja2==3.1.6 +jsonschema-specifications==2025.9.1 +jsonschema_rs==0.37.4 +MarkupSafe==3.0.3 +minijinja==2.22.0 +packaging==26.3 +pykwalify==1.8.0 +pyparsing==3.3.2 +python-dateutil==2.9.0.post0 +referencing==0.37.0 +requests==2.34.2 +requests-file==2.1.0 +roman-numerals==4.1.0 +rpds-py==0.30.0 +ruamel.yaml==0.19.1 +six==1.17.0 +snowballstemmer==3.1.1 +sphinx-data-viewer==0.1.5 +sphinxcontrib-applehelp==2.0.0 +sphinxcontrib-devhelp==2.0.0 +sphinxcontrib-htmlhelp==2.1.0 +sphinxcontrib-jquery==4.1 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==2.0.0 +sphinxcontrib-serializinghtml==2.0.0 +typing_extensions==4.16.0 +urllib3==2.7.0 diff --git a/doc/site/index.html b/doc/site/index.html new file mode 100644 index 0000000..4b992fd --- /dev/null +++ b/doc/site/index.html @@ -0,0 +1,46 @@ + + + + + + + zdocs — Documentation + + + +
+

zdocs — Documentation

+

A documentation engine for Zephyr projects. It builds Sphinx and Doxygen documents as one set, with cross-references in both directions.

+
    +
  • zdocs Manual — tutorials, how-to guides, reference and explanation.
  • +
  • zdocs API Reference — the Python modules and the CMake commands of the engine.
  • +
+ +
+ + diff --git a/doc/west.yml b/doc/west.yml new file mode 100644 index 0000000..0c68829 --- /dev/null +++ b/doc/west.yml @@ -0,0 +1,29 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 +# +# West manifest for the documentation workflow (.github/workflows/docs.yml). +# It is not a manifest for consumers: they add zdocs as a project of their +# own manifest. +# +# west init -l --mf doc/west.yml zdocs +# west update --narrow --fetch-opt=--depth=1 +# +# The build of doc/ needs Zephyr only as a CMake package +# (find_package(Zephyr COMPONENTS doc)) and its doc/_extensions. No +# toolchain, no SDK, no HAL. So the manifest has no `import:` key. + +manifest: + version: "0.13" + + remotes: + - name: zephyrproject + url-base: https://github.com/zephyrproject-rtos + + self: + path: zdocs + + projects: + - name: zephyr + remote: zephyrproject + revision: v4.4.1 From 9073f551be5b26626bb2dbd38dd63252c81b8fe5 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Fri, 2 Oct 2026 14:48:45 +0200 Subject: [PATCH 2/2] build: sphinx: pin sphinx-needs 8.3.0, the reference version The engine pinned sphinx-needs 8.1.1. The working environments of the zdocs consumers (the safety docset, the safety toolbox) and the CI lock of the documentation workflow use 8.3.0, the reference version. The engine pin now matches, so doc/requirements-ci.txt no longer lists an exception. Checked with a new venv from doc/requirements-ci.txt: the unit suite passes (147), the docs build has doc-check OK and 0 warnings, the site check and the HTTP crawl find 0 broken links. The zdocs-tests acceptance suite gives the same result as at origin/main (273 passed; the failures are the steps that need unmerged engine changes). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- doc/requirements-ci.txt | 11 +++++------ sphinx/requirements-doc.txt | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/doc/requirements-ci.txt b/doc/requirements-ci.txt index 4f9472d..9e43226 100644 --- a/doc/requirements-ci.txt +++ b/doc/requirements-ci.txt @@ -8,11 +8,10 @@ # # pip install -r doc/requirements-ci.txt # -# The engine versions are the ones in sphinx/requirements-doc.txt, with one -# exception: sphinx-needs is 8.3.0 here, the reference version, and -# sphinx/requirements-doc.txt still pins 8.1.1. The document-set version is -# the one in doc/requirements.txt. Change a version here only together with -# the same change in those files, and with a full docs build. sphinx-autobuild and sphobjinv are not here: the workflow does +# The engine versions are the ones in sphinx/requirements-doc.txt, and the +# document-set version is the one in doc/requirements.txt. Change a version +# here only together with the same change in those files, and with a full +# docs build. sphinx-autobuild and sphobjinv are not here: the workflow does # not use the html-live targets or `needs: source: inventory`. # Workspace and Zephyr module discovery (find_package(Zephyr) runs @@ -21,7 +20,7 @@ west==1.4.0 jsonschema==4.26.0 PyYAML==6.0.3 -# The zdocs engine (sphinx/requirements-doc.txt; sphinx-needs: see above). +# The zdocs engine (sphinx/requirements-doc.txt). Sphinx==9.1.0 sphinx_rtd_theme==3.1.0 sphinx-needs==8.3.0 diff --git a/sphinx/requirements-doc.txt b/sphinx/requirements-doc.txt index bb35db4..1121579 100644 --- a/sphinx/requirements-doc.txt +++ b/sphinx/requirements-doc.txt @@ -24,7 +24,7 @@ sphinx_rtd_theme==3.1.0 # -- Loaded unconditionally by zdocs_conf.py's engine_extensions --------------- # sphinx-needs: structured requirements/specifications and the traceability # graph; also the needs.json export the registry's cross-document import reads. -sphinx-needs==8.1.1 +sphinx-needs==8.3.0 # sphinxcontrib-doxylink: ::`symbol` references into the doxygen # documents. The map is derived from documents.yaml (docrefs.Refs.doxylink). sphinxcontrib-doxylink==1.13.0