Skip to content

docs: add bazel sphinx_build_test replicating ReadTheDocs CI#10184

Open
alokkumardalei-wq wants to merge 3 commits into
The-OpenROAD-Project:masterfrom
alokkumardalei-wq:feature/bazel-sphinx-test
Open

docs: add bazel sphinx_build_test replicating ReadTheDocs CI#10184
alokkumardalei-wq wants to merge 3 commits into
The-OpenROAD-Project:masterfrom
alokkumardalei-wq:feature/bazel-sphinx-test

Conversation

@alokkumardalei-wq

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds a hermetic Bazel py_test target that runs the same Sphinx documentation build performed by readthedocs.org/openroad, catching broken cross-references, malformed markup, missing files, and toc.yml issues locally before they reach CI.

  • //docs:sphinx_build_test runs sphinx-build -b html with pinned Python deps from @openroad-pip (no system Sphinx required).
  • Tagged doc_check for filtering: bazelisk test --test_tag_filters=doc_check //docs/....
  • Mirrors conf.py setup (main symlink, README2.md prefix swap, messages glossary generation, doxygen stub) and strips bazel-* symlinks to prevent recursive scanning.

Fixes #9885

Type of Change

  • New feature
  • Refactoring
  • Documentation update

Verification

  • I have verified that the local build succeeds (./etc/Build.sh).
  • bazelisk test //docs:sphinx_build_test
  • My code follows the repository's formatting guidelines.
  • I have signed my commits (DCO).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a local Sphinx documentation build test using Bazel to replicate the ReadTheDocs CI environment. It adds several Sphinx-related dependencies to the requirements and implements a new py_test target that verifies the documentation build process. Feedback on the implementation focuses on making the test script less intrusive and more robust. Key recommendations include avoiding the destructive removal of bazel-* symlinks by utilizing Sphinx's exclude_patterns configuration, adding check=True to subprocess calls to ensure failures are properly caught, and implementing safer cleanup logic for the _readthedocs directory to prevent accidental deletion of existing user data.

Comment thread docs/sphinx_build_test.py Outdated
Comment on lines +33 to +41
def _remove_bazel_symlinks(repo_root: str) -> list[str]:
"""Remove bazel-* convenience symlinks that cause Sphinx to recurse."""
removed = []
for entry in os.listdir(repo_root):
path = os.path.join(repo_root, entry)
if entry.startswith("bazel-") and os.path.islink(path):
os.unlink(path)
removed.append(path)
return removed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The _remove_bazel_symlinks function is destructive as it permanently removes convenience symlinks (like bazel-bin, bazel-out) from the user's workspace. Since this test is intended to be run locally, this side effect is intrusive and requires the user to manually recreate the symlinks after running the test.

A better approach is to exclude these patterns in the Sphinx configuration. Since the main symlink (created at line 59) makes the repo root visible to Sphinx, you should add main/bazel-* to the exclude_patterns list in docs/conf.py instead of deleting the files.

Comment thread docs/sphinx_build_test.py Outdated
Comment on lines +79 to +80
# Remove bazel-* symlinks to prevent recursive scanning
_remove_bazel_symlinks(repo_root)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

As mentioned in the feedback for the function definition, this call should be removed in favor of using exclude_patterns in docs/conf.py to avoid destructive side effects on the user's workspace.

Comment thread docs/sphinx_build_test.py Outdated
Comment on lines +68 to +72
subprocess.run(
[sys.executable, "getMessages.py"],
cwd=docs_dir,
capture_output=True,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The subprocess.run call for getMessages.py should include check=True. If the glossary generation fails, the test should stop immediately rather than proceeding with an incomplete build environment.

Suggested change
subprocess.run(
[sys.executable, "getMessages.py"],
cwd=docs_dir,
capture_output=True,
)
subprocess.run(
[sys.executable, "getMessages.py"],
cwd=docs_dir,
capture_output=True,
check=True,
)

Comment thread docs/sphinx_build_test.py Outdated
Comment on lines +74 to +77
# Stub doxygen output directory
doxygen_dir = os.path.join(repo_root, "_readthedocs", "html", "doxygen_output")
os.makedirs(doxygen_dir, exist_ok=True)
cleanup_paths.append(os.path.join(repo_root, "_readthedocs"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Adding _readthedocs to cleanup_paths unconditionally can lead to accidental deletion of existing user data if that directory already existed. It is safer to only add it to the cleanup list if the test script actually created it.

Suggested change
# Stub doxygen output directory
doxygen_dir = os.path.join(repo_root, "_readthedocs", "html", "doxygen_output")
os.makedirs(doxygen_dir, exist_ok=True)
cleanup_paths.append(os.path.join(repo_root, "_readthedocs"))
# Stub doxygen output directory
rt_dir = os.path.join(repo_root, "_readthedocs")
rt_existed = os.path.exists(rt_dir)
doxygen_dir = os.path.join(rt_dir, "html", "doxygen_output")
os.makedirs(doxygen_dir, exist_ok=True)
if not rt_existed:
cleanup_paths.append(rt_dir)

Comment thread docs/sphinx_build_test.py
Comment on lines +105 to +109
subprocess.run(
[sys.executable, revert],
cwd=docs_dir,
capture_output=True,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The revert-links.py script should also be run with check=True to ensure that any failure in reverting the documentation state is reported as a test failure.

Suggested change
subprocess.run(
[sys.executable, revert],
cwd=docs_dir,
capture_output=True,
)
subprocess.run(
[sys.executable, revert],
cwd=docs_dir,
capture_output=True,
check=True,
)

@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@alokkumardalei-wq alokkumardalei-wq force-pushed the feature/bazel-sphinx-test branch from 2c220e2 to 5892e47 Compare April 19, 2026 10:32
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@alokkumardalei-wq alokkumardalei-wq force-pushed the feature/bazel-sphinx-test branch from 5892e47 to d290e9b Compare April 19, 2026 10:38
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@alokkumardalei-wq alokkumardalei-wq force-pushed the feature/bazel-sphinx-test branch from d290e9b to 27036d6 Compare April 19, 2026 14:05
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@alokkumardalei-wq alokkumardalei-wq force-pushed the feature/bazel-sphinx-test branch from 27036d6 to 66f8a3c Compare April 19, 2026 17:25
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@alokkumardalei-wq alokkumardalei-wq force-pushed the feature/bazel-sphinx-test branch from 66f8a3c to 90a03ce Compare April 20, 2026 01:07
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@alokkumardalei-wq alokkumardalei-wq force-pushed the feature/bazel-sphinx-test branch from 90a03ce to 7a55efa Compare April 20, 2026 02:35
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@alokkumardalei-wq alokkumardalei-wq force-pushed the feature/bazel-sphinx-test branch from 7a55efa to a405d28 Compare April 20, 2026 03:40
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@alokkumardalei-wq alokkumardalei-wq force-pushed the feature/bazel-sphinx-test branch from a405d28 to 2c7d8e3 Compare April 20, 2026 08:25
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@alokkumardalei-wq

Copy link
Copy Markdown
Contributor Author

Hello @oharboe and @maliberty , all checks are passing please have a look when you get time . Happy to get any feedback on this .

Thank you !

@oharboe

oharboe commented Apr 20, 2026

Copy link
Copy Markdown
Collaborator

Thank you for doing this! I love having all tests reproducible locally rather than digging into mystical severs. Also this should make CI more robust: it depends on fewer servers... I'll leave review to @maliberty

@maliberty

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2c7d8e3a1c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/sphinx_build_test.py Outdated
from the workspace, the script lives in the real ``docs/`` directory.
In both cases the docs content sits alongside this file.
"""
return os.path.dirname(os.path.realpath(__file__))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve docs path without dereferencing runfile symlinks

Using os.path.realpath(__file__) here can escape Bazel’s runfiles tree and point docs_dir at the live workspace when sources are symlinked, so this test may read/write real repo files instead of declared data inputs. In that mode the sanity check for staged runfiles becomes ineffective, and the test can mutate local files (README.md, docs/main) as part of setup/cleanup, which makes the check non-hermetic and can leave a dirty workspace if the run is interrupted.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve docs path without dereferencing runfile symlinks

Using os.path.realpath(__file__) here can escape Bazel’s runfiles tree and point docs_dir at the live workspace when sources are symlinked, so this test may read/write real repo files instead of declared data inputs. In that mode the sanity check for staged runfiles becomes ineffective, and the test can mutate local files (README.md, docs/main) as part of setup/cleanup, which makes the check non-hermetic and can leave a dirty workspace if the run is interrupted.

Useful? React with 👍 / 👎.

Hello @maliberty @luarss , thanks for the feedback . I have have addressed this, please have a look whrn yo get time. Happy to address any feedback on this further.

Thank you !

@maliberty maliberty requested a review from luarss April 20, 2026 16:51
Adds a hermetic Bazel `py_test` target that runs the same Sphinx
documentation build performed by readthedocs.org/openroad, catching
broken cross-references, malformed markup, missing files, and toc.yml
issues locally before they reach CI.

- `//docs:sphinx_build_test` runs `sphinx-build -b html` with pinned
  Python deps from `@openroad-pip` (no system Sphinx required).
- Tagged `doc_check` for filtering: `bazelisk test --test_tag_filters=doc_check //docs/...`.
- Mirrors conf.py setup (main symlink, README2.md prefix swap, messages
  glossary generation, doxygen stub) and strips `bazel-*` symlinks to
  prevent recursive scanning.

Fixes The-OpenROAD-Project#9885

Signed-off-by: alokkumardalei-wq <alokkumardalei2@gmail.com>
@alokkumardalei-wq alokkumardalei-wq force-pushed the feature/bazel-sphinx-test branch from 2c7d8e3 to 589c095 Compare April 20, 2026 18:53
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

Comment thread bazel/requirements.in
tclint==0.7.0

# Sphinx documentation build (replicates ReadTheDocs CI check)
# See: https://github.com/The-OpenROAD-Project/OpenROAD/issues/9885

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which python version is this requirements.in for? also can you please lock them with ==

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@luarss done, pinned all Sphinx deps with == in 8aac486 and added a header clarifying this is the Python 3.13 toolchain's input (the lock file is bazel/requirements_lock_3_13.txt). Regenerated via bazelisk run //bazel:requirements.update; the lock is unchanged since the pins match the previously resolved versions.

Comment thread docs/conf.py Outdated
"main/src/odb/src/def/doc/README.md",
"main/src/odb/src/lef/README.md",
"main/docs",
"main/bazel-*",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @luarss ,good catch removed in 8aac486. This was defensive for local-dev: running sphinx-build docs/ from a workspace that's been bazel-built makes Sphinx recurse into the bazel-bin/bazel-out/bazel-testlogs convenience symlinks (the main/docs exclusion doesn't cover them). It isn't needed for this PR — ReadTheDocs CI doesn't run Bazel, and runfiles don't contain bazel-* symlinks. Happy to send it as a small follow-up if you'd like that kept for the local workflow.

Comment thread docs/sphinx_build_test.py Outdated
Comment on lines +23 to +33
def _find_docs_dir() -> str:
"""Locate the docs directory at test runtime.

Under ``bazel test``, sources declared as ``data`` are staged in the
runfiles tree next to this script. Under a plain Python invocation
from the workspace, the script lives in the real ``docs/`` directory.
In both cases the docs content sits alongside this file. ``abspath``
(not ``realpath``) is used so the script stays inside the runfiles
tree instead of resolving symlinks back to the live workspace.
"""
return os.path.dirname(os.path.abspath(__file__))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this not just a simple line for variable DOCS_DIR?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@luarss agreed ,replaced the helper with a module-level DOCS_DIR = os.path.dirname(os.path.abspath(__file__)) constant in 8aac486.

Comment thread docs/sphinx_build_test.py

try:
# --- Pre-build setup (mirrors conf.py setup()) ---

@luarss luarss Apr 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should not mirror conf.py setup. This leads to divergent sources of truth. Instead - find a way to reference that file directly, or reference their main() function if applicable.

edit: Do not simply just copy, use a symlink or reuse utilities from that conf.py file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@luarss you're right refactored in 8aac486. The test no longer mirrors any of conf.py's setup(app) logic. It just chdirs into docs/ and calls sphinx.cmd.build.main(...), which loads conf.py and Sphinx invokes setup(app) for us. The only piece I had to add was staging //:README.md in the runfiles tree (so conf.py's shutil.copy("main/README.md", ...) can resolve it), and I still invoke revert-links.py post-build to undo the in-place swap_prefix mutations.

Simplify the test per @luarss's review (#4148341864):

- Pin Sphinx deps with `==` in bazel/requirements.in and clarify
  the Python 3.13 target; regenerated lock file is unchanged.
- Drop "main/bazel-*" from conf.py exclude_patterns (scope creep
  for this PR; defensive only for local-dev sphinx-build from a
  workspace that has been bazel-built).
- Replace _find_docs_dir() helper with a DOCS_DIR module constant.
- Stop mirroring conf.py's setup() in the test. Stage //:README.md
  in runfiles and rely on Sphinx to call conf.py's setup(app)
  directly; revert-links.py is still invoked post-build to undo
  the in-place swap_prefix mutations on README.md / README2.md.

Verified locally:
  bazelisk test //docs:sphinx_build_test
  //docs:sphinx_build_test PASSED in 2.8s

Signed-off-by: alokkumardalei-wq <alokkumardalei2@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@alokkumardalei-wq

Copy link
Copy Markdown
Contributor Author

Hello @luarss @maliberty I have tried to address all your feedback please have look when you get time.

Happy to get any feedback on this further.

Thank you !

@alokkumardalei-wq

Copy link
Copy Markdown
Contributor Author

Hello @luarss and @maliberty , all checks are passing now please have a look when you get time .

Happy to take any further feedback .

Thank you !

@maliberty

Copy link
Copy Markdown
Member

@luarss please look when you can

@luarss luarss left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test runs, but no-sandbox + local break Bazel's sandbox isolation: conf.py's setup(app) mutates workspace source files (README.md) through runfiles symlinks. Two suggestions below to fix this and let the test run fully hermetically.

Comment thread docs/BUILD.bazel
Comment on lines +31 to +35
tags = [
"doc_check",
"local",
"no-sandbox",
],

@luarss luarss Jun 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignore

Comment thread docs/sphinx_build_test.py
Comment on lines +17 to +70
import os
import shutil
import subprocess
import sys
import tempfile

# abspath (not realpath) so the path stays inside the runfiles tree instead
# of resolving symlinks back to the live workspace.
DOCS_DIR = os.path.dirname(os.path.abspath(__file__))


def main() -> int:
build_output = tempfile.mkdtemp(prefix="sphinx_build_")
saved_cwd = os.getcwd()
try:
# conf.py's setup(app) hook uses cwd-relative paths like "./main"
# and "../README.md", so it must run with docs/ as cwd. Sphinx
# invokes setup(app) automatically when it loads conf.py below.
os.chdir(DOCS_DIR)

# Calling sphinx via subprocess would fail because a child python3
# doesn't inherit the Bazel runfiles PYTHONPATH used by this test.
from sphinx.cmd.build import main as sphinx_main

args = ["-b", "html", "-T", "-q"]
if not shutil.which("mmdc"):
args.extend(["-D", "mermaid_output_format=raw"])
args.extend([DOCS_DIR, build_output])

print("Running sphinx-build...", flush=True)
returncode = sphinx_main(args)

# conf.py's setup(app) rewrites README.md and README2.md in place.
# revert-links.py undoes those mutations so the test leaves no trace.
revert = os.path.join(DOCS_DIR, "revert-links.py")
if os.path.isfile(revert):
subprocess.run(
[sys.executable, revert],
cwd=DOCS_DIR,
capture_output=True,
check=True,
)

if returncode != 0:
print(
f"FAILED: sphinx-build exited with status {returncode}",
file=sys.stderr,
)
return 1
print("PASSED: Sphinx documentation built successfully.")
return 0
finally:
os.chdir(saved_cwd)
shutil.rmtree(build_output, ignore_errors=True)

@luarss luarss Jun 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignore

@luarss luarss left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maliberty LGTM. Please ignore my previous review - it was about refactoring to bazel-syntax, but in doing so led to multiple sources of truth for docs/conf.py logic - we should use a single source like what is done here.

@maliberty

Copy link
Copy Markdown
Member

conflict to resolve

@luarss

luarss commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

@maliberty Shall I start a new PR to salvage this?

Signed-off-by: Alok Kumar Dalei <alokkumardalei2@gmail.com>
@alokkumardalei-wq alokkumardalei-wq requested a review from a team as a code owner June 4, 2026 09:31
@luarss

luarss commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

@alokkumardalei-wq Almost there - please fix bazel.lock

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add bazelisk test target that does whatever testing the read the docs/readthedocs.org:openroad does

4 participants