Skip to content

Commit 8e4e16c

Browse files
authored
Merge pull request #188 from posit-dev/fix-multi-version-build-checks
fix: add build checks for versioned sites
2 parents 146e7cd + f24a0d6 commit 8e4e16c

8 files changed

Lines changed: 234 additions & 14 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ __marimo__/
146146

147147
# Great Docs build directory (ephemeral, do not commit)
148148
great-docs/
149+
_great_docs_build/
149150
.great-docs-build/
150151
.great-docs-cache/
151152
.great-docs/

great_docs/_versioned_build.py

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1761,14 +1761,15 @@ def run_versioned_build(
17611761
"errors": ["No matching versions to build"],
17621762
}
17631763

1764-
build_root = project_root / ".great-docs-build"
1764+
build_root = project_root / "_great_docs_build"
17651765
if build_root.exists():
17661766
shutil.rmtree(build_root)
17671767
build_root.mkdir(parents=True)
17681768

17691769
# --- Stage 1: Preprocess each version ---
17701770
pages_by_version: dict[str, list[str]] = {}
17711771
build_dirs: list[Path] = []
1772+
errors: list[str] = []
17721773

17731774
for entry in targets:
17741775
ver_dir = _version_build_dir(build_root, entry, latest_tag)
@@ -1786,6 +1787,42 @@ def run_versioned_build(
17861787
pages_by_version[entry.tag] = pages
17871788
build_dirs.append(ver_dir)
17881789

1790+
# Map build dir to version tag (for pre-render diagnostics)
1791+
dir_to_tag_pre = {str(_version_build_dir(build_root, e, latest_tag)): e.tag for e in targets}
1792+
1793+
# --- Pre-render sanity check ---
1794+
# Verify each build directory has renderable .qmd files and a valid _quarto.yml.
1795+
# If not, report an error instead of silently producing an empty site.
1796+
for ver_dir in build_dirs:
1797+
qmd_count = sum(
1798+
1 for _ in ver_dir.rglob("*.qmd") if not str(_.relative_to(ver_dir)).startswith("_")
1799+
)
1800+
quarto_yml = ver_dir / "_quarto.yml"
1801+
if not quarto_yml.exists():
1802+
tag = dir_to_tag_pre.get(str(ver_dir), str(ver_dir))
1803+
errors.append(
1804+
f"Version {tag}: _quarto.yml missing from build directory. "
1805+
f"This indicates a preprocessing failure."
1806+
)
1807+
elif qmd_count == 0:
1808+
tag = dir_to_tag_pre.get(str(ver_dir), str(ver_dir))
1809+
errors.append(
1810+
f"Version {tag}: No .qmd files found in build directory after preprocessing. "
1811+
f"All pages may have been excluded by version scoping."
1812+
)
1813+
1814+
if errors:
1815+
# All versions have fatal pre-render issues; abort early.
1816+
if on_renders_done:
1817+
on_renders_done()
1818+
return {
1819+
"success": False,
1820+
"versions_built": [],
1821+
"pages_by_version": pages_by_version,
1822+
"timings_by_version": {},
1823+
"errors": errors,
1824+
}
1825+
17891826
# --- Stage 2: Parallel renders ---
17901827
render_results = render_versions_parallel(
17911828
build_dirs,
@@ -1794,7 +1831,7 @@ def run_versioned_build(
17941831
progress_callback=progress_callback,
17951832
)
17961833

1797-
errors: list[str] = []
1834+
errors_render: list[str] = []
17981835
versions_built: list[str] = []
17991836
timings_by_version: dict[str, list[dict[str, Any]]] = {}
18001837

@@ -1804,11 +1841,41 @@ def run_versioned_build(
18041841
for build_dir, returncode, stdout, stderr, page_timings in render_results:
18051842
tag = dir_to_tag.get(build_dir, build_dir)
18061843
if returncode == 0:
1807-
versions_built.append(tag)
1808-
if page_timings:
1809-
timings_by_version[tag] = page_timings
1844+
# Post-render validation: verify Quarto actually produced HTML pages.
1845+
# Quarto may exit 0 without rendering anything (e.g. if it cannot find
1846+
# renderable files or has a configuration issue). Detect this and report
1847+
# a meaningful error instead of silently producing an empty site.
1848+
site_dir = Path(build_dir) / "_site"
1849+
html_files = list(site_dir.rglob("*.html")) if site_dir.exists() else []
1850+
if not html_files:
1851+
# Gather diagnostic info
1852+
qmd_files = list(Path(build_dir).rglob("*.qmd"))
1853+
diag_parts = [
1854+
f"Version {tag}: Quarto exited successfully but produced no HTML pages.",
1855+
f" Build directory: {build_dir}",
1856+
f" .qmd files present: {len(qmd_files)}",
1857+
]
1858+
if stderr.strip():
1859+
# Limit stderr to avoid flooding logs
1860+
stderr_preview = stderr.strip()[:500]
1861+
diag_parts.append(f" Quarto stderr: {stderr_preview}")
1862+
else:
1863+
diag_parts.append(" Quarto stderr: (empty)")
1864+
diag_parts.append(
1865+
" This may indicate a Quarto configuration issue, missing dependencies, "
1866+
"or an incompatibility with the build environment."
1867+
)
1868+
errors_render.append("\n".join(diag_parts))
1869+
else:
1870+
versions_built.append(tag)
1871+
if page_timings:
1872+
timings_by_version[tag] = page_timings
18101873
else:
1811-
errors.append(f"Version {tag}: Quarto render failed (exit {returncode})\n{stderr}")
1874+
errors_render.append(
1875+
f"Version {tag}: Quarto render failed (exit {returncode})\n{stderr}"
1876+
)
1877+
1878+
errors.extend(errors_render)
18121879

18131880
# Notify caller that rendering is complete (e.g. to finish progress bars)
18141881
if on_renders_done:

great_docs/assets/restore-freeze.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
# Quarto sets the working directory to the Quarto project directory.
55
# In a normal build this is great-docs/ (one level below project root).
6-
# In a versioned build this is .great-docs-build/<version>/ (two levels below).
6+
# In a versioned build this is _great_docs_build/<version>/ (two levels below).
77
# We find the project root by walking upward until we find great-docs.yml.
88
build_dir = Path.cwd()
99

great_docs/core.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,7 @@ def _update_project_gitignore(self, force: bool = False) -> None:
590590

591591
# Versioning build artifacts (added when versions are configured)
592592
versioning_entries = [
593+
"_great_docs_build/",
593594
".great-docs-build/",
594595
".great-docs-cache/",
595596
".great-docs/",

tests/test_great_docs.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7968,6 +7968,7 @@ def test_update_gitignore_force_creates_new():
79687968
content = gitignore.read_text()
79697969

79707970
assert "great-docs/" in content
7971+
assert "_great_docs_build/" in content
79717972
assert ".great-docs-build/" in content
79727973
assert ".great-docs-cache/" in content
79737974
assert ".great-docs/" in content
@@ -7986,6 +7987,7 @@ def test_update_gitignore_force_appends_to_existing():
79867987

79877988
assert "__pycache__/" in content
79887989
assert "great-docs/" in content
7990+
assert "_great_docs_build/" in content
79897991
assert ".great-docs-build/" in content
79907992
assert ".great-docs-cache/" in content
79917993
assert ".great-docs/" in content
@@ -7995,7 +7997,9 @@ def test_update_gitignore_skip_when_already_present():
79957997
"""_update_project_gitignore does not duplicate entries already present."""
79967998
with tempfile.TemporaryDirectory() as tmp_dir:
79977999
gitignore = Path(tmp_dir) / ".gitignore"
7998-
gitignore.write_text("great-docs/\n.great-docs-build/\n.great-docs-cache/\n.great-docs/\n")
8000+
gitignore.write_text(
8001+
"great-docs/\n_great_docs_build/\n.great-docs-build/\n.great-docs-cache/\n.great-docs/\n"
8002+
)
79998003

80008004
docs = GreatDocs(project_path=tmp_dir)
80018005
docs._update_project_gitignore(force=True)
@@ -8005,6 +8009,7 @@ def test_update_gitignore_skip_when_already_present():
80058009
lines = content.splitlines()
80068010

80078011
assert lines.count("great-docs/") == 1
8012+
assert lines.count("_great_docs_build/") == 1
80088013
assert lines.count(".great-docs-build/") == 1
80098014
assert lines.count(".great-docs-cache/") == 1
80108015
assert lines.count(".great-docs/") == 1
@@ -25582,7 +25587,7 @@ def test_update_gitignore_already_present():
2558225587
with tempfile.TemporaryDirectory() as tmp_dir:
2558325588
gitignore = Path(tmp_dir) / ".gitignore"
2558425589
gitignore.write_text(
25585-
"great-docs/\n.great-docs-build/\n.great-docs-cache/\n.great-docs/\n",
25590+
"great-docs/\n_great_docs_build/\n.great-docs-build/\n.great-docs-cache/\n.great-docs/\n",
2558625591
encoding="utf-8",
2558725592
)
2558825593

@@ -25593,6 +25598,7 @@ def test_update_gitignore_already_present():
2559325598
lines = content.splitlines()
2559425599
# Should not have doubled any entry
2559525600
assert lines.count("great-docs/") == 1
25601+
assert lines.count("_great_docs_build/") == 1
2559625602
assert lines.count(".great-docs-build/") == 1
2559725603
assert lines.count(".great-docs-cache/") == 1
2559825604
assert lines.count(".great-docs/") == 1

tests/test_versioned_build.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3439,3 +3439,148 @@ def test_removes_href_dict_with_missing_md_file(self, tmp_path: Path):
34393439
result = _prune_sidebar_contents(contents, tmp_path)
34403440
assert len(result) == 1
34413441
assert result[0]["href"] == "intro.md"
3442+
3443+
3444+
# ---------------------------------------------------------------------------
3445+
# run_versioned_build
3446+
# ---------------------------------------------------------------------------
3447+
3448+
3449+
class TestRunVersionedBuildEmptyRender:
3450+
"""Tests for detecting when Quarto exits 0 but produces no HTML."""
3451+
3452+
def test_quarto_exit_zero_no_html_reports_error(self, tmp_path: Path):
3453+
"""When Quarto succeeds (exit 0) but creates no HTML, report failure."""
3454+
from unittest.mock import patch
3455+
3456+
source = tmp_path / "source"
3457+
_make_source_tree(source, {"index.qmd": "---\ntitle: Hi\n---\nContent"})
3458+
3459+
project_root = tmp_path / "project"
3460+
project_root.mkdir()
3461+
3462+
def mock_render_no_output(build_dirs, **kwargs):
3463+
# Quarto exits 0 but creates NO _site directory (empty render)
3464+
return [(str(d), 0, "", "", []) for d in build_dirs]
3465+
3466+
with patch(
3467+
"great_docs._versioned_build.render_versions_parallel",
3468+
side_effect=mock_render_no_output,
3469+
):
3470+
result = run_versioned_build(
3471+
source_dir=source,
3472+
project_root=project_root,
3473+
versions_config=["0.3"],
3474+
)
3475+
3476+
assert result["success"] is False
3477+
assert result["versions_built"] == []
3478+
assert len(result["errors"]) >= 1
3479+
assert "no HTML pages" in result["errors"][0]
3480+
3481+
def test_quarto_exit_zero_empty_site_dir_reports_error(self, tmp_path: Path):
3482+
"""When Quarto creates _site but it's empty, report failure."""
3483+
from unittest.mock import patch
3484+
3485+
source = tmp_path / "source"
3486+
_make_source_tree(source, {"index.qmd": "---\ntitle: Hi\n---\nContent"})
3487+
3488+
project_root = tmp_path / "project"
3489+
project_root.mkdir()
3490+
3491+
def mock_render_empty_site(build_dirs, **kwargs):
3492+
results = []
3493+
for d in build_dirs:
3494+
# Create _site but with no HTML files
3495+
site_dir = d / "_site"
3496+
site_dir.mkdir(parents=True, exist_ok=True)
3497+
(site_dir / "sitemap.xml").write_text("<urlset/>")
3498+
results.append((str(d), 0, "", "", []))
3499+
return results
3500+
3501+
with patch(
3502+
"great_docs._versioned_build.render_versions_parallel",
3503+
side_effect=mock_render_empty_site,
3504+
):
3505+
result = run_versioned_build(
3506+
source_dir=source,
3507+
project_root=project_root,
3508+
versions_config=["0.3", "0.2"],
3509+
)
3510+
3511+
assert result["success"] is False
3512+
assert result["versions_built"] == []
3513+
assert len(result["errors"]) == 2
3514+
3515+
for err in result["errors"]:
3516+
assert "no HTML pages" in err
3517+
3518+
def test_pre_render_check_no_qmd_files(self, tmp_path: Path):
3519+
"""When preprocessing removes all .qmd files, abort before render."""
3520+
from unittest.mock import patch
3521+
3522+
source = tmp_path / "source"
3523+
source.mkdir(parents=True)
3524+
# Only _quarto.yml, no .qmd files
3525+
(source / "_quarto.yml").write_text(
3526+
"project:\n type: website\n output-dir: _site\nwebsite:\n title: Test\n"
3527+
)
3528+
3529+
project_root = tmp_path / "project"
3530+
project_root.mkdir()
3531+
3532+
render_called = []
3533+
3534+
def mock_render(build_dirs, **kwargs):
3535+
render_called.append(True)
3536+
return [(str(d), 0, "", "", []) for d in build_dirs]
3537+
3538+
with patch(
3539+
"great_docs._versioned_build.render_versions_parallel",
3540+
side_effect=mock_render,
3541+
):
3542+
result = run_versioned_build(
3543+
source_dir=source,
3544+
project_root=project_root,
3545+
versions_config=["0.3"],
3546+
)
3547+
3548+
assert result["success"] is False
3549+
assert "No .qmd files" in result["errors"][0]
3550+
3551+
# Render should NOT have been called
3552+
assert render_called == []
3553+
3554+
def test_pre_render_check_missing_quarto_yml(self, tmp_path: Path):
3555+
"""When _quarto.yml is missing from build dir, abort before render."""
3556+
from unittest.mock import patch
3557+
3558+
source = tmp_path / "source"
3559+
source.mkdir(parents=True)
3560+
# Create a .qmd file but no _quarto.yml
3561+
(source / "index.qmd").write_text("---\ntitle: Hi\n---\nContent")
3562+
3563+
project_root = tmp_path / "project"
3564+
project_root.mkdir()
3565+
3566+
render_called = []
3567+
3568+
def mock_render(build_dirs, **kwargs):
3569+
render_called.append(True)
3570+
return [(str(d), 0, "", "", []) for d in build_dirs]
3571+
3572+
with patch(
3573+
"great_docs._versioned_build.render_versions_parallel",
3574+
side_effect=mock_render,
3575+
):
3576+
result = run_versioned_build(
3577+
source_dir=source,
3578+
project_root=project_root,
3579+
versions_config=["0.3"],
3580+
)
3581+
3582+
assert result["success"] is False
3583+
assert "_quarto.yml missing" in result["errors"][0]
3584+
3585+
# Render should NOT have been called
3586+
assert render_called == []

tests/test_versioning_e2e.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ def run_mock_versioned_build(
185185
targets = list(versions)
186186

187187
# --- Stage 1: Preprocess ---
188-
build_root = project_root / ".great-docs-build"
188+
build_root = project_root / "_great_docs_build"
189189
build_root.mkdir(parents=True)
190190

191191
pages_by_version: dict[str, list[str]] = {}
@@ -2049,7 +2049,7 @@ def site(self, tmp_path):
20492049
latest = get_latest_version(versions)
20502050
latest_tag = latest.tag
20512051

2052-
build_root = project_root / ".great-docs-build"
2052+
build_root = project_root / "_great_docs_build"
20532053
build_root.mkdir()
20542054

20552055
from great_docs._versioned_build import _rewrite_quarto_yml_for_version
@@ -2114,7 +2114,7 @@ def site(self, tmp_path):
21142114
latest = get_latest_version(versions)
21152115
latest_tag = latest.tag
21162116

2117-
build_root = project_root / ".great-docs-build"
2117+
build_root = project_root / "_great_docs_build"
21182118
build_root.mkdir()
21192119

21202120
from great_docs._versioned_build import _rewrite_quarto_yml_for_version

user_guide/30-multi-version-docs.qmd

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -602,13 +602,13 @@ Because each version is rendered independently, the build is embarrassingly para
602602

603603
Versioned builds create two directories in your project root:
604604

605-
- **`.great-docs-build/`**: temporary staging directory where per-version source trees are prepared and rendered. Automatically cleaned and recreated at the start of every build. No manual maintenance needed.
605+
- **`_great_docs_build/`**: temporary staging directory where per-version source trees are prepared and rendered. Automatically cleaned and recreated at the start of every build. No manual maintenance needed.
606606
- **`.great-docs-cache/`**: persistent cache for API introspection results from Strategy B (`git_ref`). Stores one JSON snapshot per version so subsequent builds skip the expensive checkout-and-introspect step. You can safely delete this directory at any time to force a fresh introspection (the next build will just take longer).
607607

608608
Both directories should be added to your `.gitignore`. Great Docs does this automatically when you run `great-docs init`, but if you set up versioning on an existing project you can add them manually:
609609

610610
```{.text filename=".gitignore"}
611-
.great-docs-build/
611+
_great_docs_build/
612612
.great-docs-cache/
613613
```
614614

0 commit comments

Comments
 (0)