From 05bfdfe0d399a9ef81581edac475aa3f866d6721 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 19 Sep 2026 10:31:18 -0400 Subject: [PATCH 1/7] fix: verify rendered documentation identity (#48) --- .github/workflows/ci.yml | 9 +++- .github/workflows/release.yml | 3 ++ docs/overrides/main.html | 3 ++ scripts/ci/verify_docs_deployment.py | 81 ++++++++++++++++++++++++++-- src/ml4t/models/_version.py | 2 +- tests/test_release_workflow.py | 30 ++++++++++- 6 files changed, 122 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5928dd5..bcd81c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,7 +127,14 @@ jobs: run: uv sync --locked --dev --extra docs --python "${{ steps.setup-python.outputs.python-path }}" - name: Build docs - run: uv run mkdocs build --strict + env: + ML4T_DOCS_COMMIT: ${{ github.sha }} + run: | + export ML4T_DOCS_VERSION="$(uv run python -c 'from ml4t.models import __version__; print(__version__)')" + uv run mkdocs build --strict + ML4T_DOCS_SITE=site RELEASE_COMMIT="$ML4T_DOCS_COMMIT" \ + RELEASE_VERSION="$ML4T_DOCS_VERSION" \ + uv run python scripts/ci/verify_docs_deployment.py build-candidate: name: Build Candidate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbf9671..8db2ff6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -169,6 +169,9 @@ jobs: encoding="utf-8", ) PY + ML4T_DOCS_SITE=site RELEASE_COMMIT="$ML4T_DOCS_COMMIT" \ + RELEASE_VERSION="$ML4T_DOCS_VERSION" \ + uv run python scripts/ci/verify_docs_deployment.py - name: Require deploy credentials env: diff --git a/docs/overrides/main.html b/docs/overrides/main.html index 1fd85f1..2cacd08 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -2,6 +2,9 @@ {% block extrahead %} {{ super() }} + + + None: + super().__init__() + self.values: dict[str, str] = {} + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag != "meta": + return + values = dict(attrs) + name = values.get("name") + content = values.get("content") + if name in REQUIRED_META and content is not None: + self.values[name] = content + + +def html_identity_failures(html: str, expected: dict[str, str], source: str) -> list[str]: + """Return missing or mismatched documentation identity fields.""" + parser = _MetadataParser() + parser.feed(html) + expected_meta = { + "ml4t-library": expected["library"], + "ml4t-version": expected["version"], + "ml4t-commit": expected["commit"], + } + return [ + f"{source}: {name} is {parser.values.get(name)!r}, expected {value!r}" + for name, value in expected_meta.items() + if parser.values.get(name) != value + ] + + +def verify_site(site: Path, expected: dict[str, str]) -> None: + """Verify that every rendered page exposes the expected release identity.""" + pages = sorted( + page for page in site.rglob("*.html") if "overrides" not in page.relative_to(site).parts + ) + if not pages: + raise RuntimeError(f"{site}: no rendered HTML pages found") + failures = [ + failure + for page in pages + for failure in html_identity_failures( + page.read_text(encoding="utf-8"), expected, str(page.relative_to(site)) + ) + ] + if failures: + raise RuntimeError("rendered documentation identity did not match:\n" + "\n".join(failures)) def _read_identity(url: str, commit: str, attempt: int) -> object: @@ -22,10 +74,18 @@ def _read_identity(url: str, commit: str, attempt: int) -> object: return json.load(response) +def _read_page(url: str, commit: str, attempt: int) -> str: + query = urlencode({"commit": commit, "attempt": attempt}) + request = Request(f"{url}?{query}", headers={"User-Agent": USER_AGENT}) + with urlopen(request, timeout=20) as response: + return response.read().decode("utf-8") + + def verify( - urls: tuple[str, ...], + identity_urls: tuple[str, ...], expected: dict[str, str], *, + page_urls: tuple[str, ...] = (), attempts: int = 24, retry_seconds: float = 10, ) -> None: @@ -36,9 +96,16 @@ def verify( last_error: Exception | None = None for attempt in range(attempts): try: - observed = [_read_identity(url, expected["commit"], attempt) for url in urls] + observed = [_read_identity(url, expected["commit"], attempt) for url in identity_urls] + page_failures = [ + failure + for url in page_urls + for failure in html_identity_failures( + _read_page(url, expected["commit"], attempt), expected, url + ) + ] last_error = None - if all(value == expected for value in observed): + if all(value == expected for value in observed) and not page_failures: return except (OSError, URLError, ValueError) as error: last_error = error @@ -58,12 +125,20 @@ def main() -> None: "library": "models", "version": os.environ["RELEASE_VERSION"], } + site = os.environ.get("ML4T_DOCS_SITE") + if site is not None: + verify_site(Path(site), expected) + return verify( ( "https://www.ml4trading.io/docs/models/release.json", f"https://www.ml4trading.io/docs/models/releases/{expected['version']}/release.json", ), expected, + page_urls=( + "https://www.ml4trading.io/docs/models/", + f"https://www.ml4trading.io/docs/models/releases/{expected['version']}/", + ), ) diff --git a/src/ml4t/models/_version.py b/src/ml4t/models/_version.py index b227e06..35e7d5b 100644 --- a/src/ml4t/models/_version.py +++ b/src/ml4t/models/_version.py @@ -1,3 +1,3 @@ """Package version shared by public and internal runtime metadata.""" -__version__ = "0.1.3" +__version__ = "0.1.4" diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index fe18db6..2f13419 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -168,12 +168,40 @@ def read_identity(_url: str, _commit: str, attempt: int) -> object: monkeypatch.setattr(verify_docs_deployment.time, "sleep", sleeps.append) with pytest.raises(RuntimeError, match="deployed documentation identity did not match"): - verify_docs_deployment.verify(("https://example.test/release.json",), {"commit": COMMIT}) + verify_docs_deployment.verify( + ("https://example.test/release.json",), + {"commit": COMMIT}, + ) assert attempts == list(range(24)) assert sleeps == [10] * 23 +def test_rendered_docs_expose_exact_release_identity(tmp_path: Path) -> None: + expected = {"commit": COMMIT, "library": "models", "version": __version__} + environment = { + **os.environ, + "ML4T_DOCS_COMMIT": expected["commit"], + "ML4T_DOCS_VERSION": expected["version"], + } + subprocess.run( + ["uv", "run", "mkdocs", "build", "--strict", "--site-dir", str(tmp_path)], + cwd=ROOT, + env=environment, + check=True, + ) + + verify_docs_deployment.verify_site(tmp_path, expected) + index = tmp_path / "index.html" + html = index.read_text(encoding="utf-8").replace( + f'', + '', + ) + index.write_text(html, encoding="utf-8") + with pytest.raises(RuntimeError, match="ml4t-version is 'wrong'"): + verify_docs_deployment.verify_site(tmp_path, expected) + + def test_release_workflow_reuses_one_commit_bound_candidate() -> None: ci = _workflow("ci.yml") release_workflow = _workflow("release.yml") From 608ba4cf905fd49c15ec99bf1d1e6e3f8112b420 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 19 Sep 2026 10:32:28 -0400 Subject: [PATCH 2/7] ci: keep documentation identity shell strict (#48) --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcd81c5..8fd61c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,7 +130,8 @@ jobs: env: ML4T_DOCS_COMMIT: ${{ github.sha }} run: | - export ML4T_DOCS_VERSION="$(uv run python -c 'from ml4t.models import __version__; print(__version__)')" + ML4T_DOCS_VERSION="$(uv run python -c 'from ml4t.models import __version__; print(__version__)')" + export ML4T_DOCS_VERSION uv run mkdocs build --strict ML4T_DOCS_SITE=site RELEASE_COMMIT="$ML4T_DOCS_COMMIT" \ RELEASE_VERSION="$ML4T_DOCS_VERSION" \ From 9b6f5b846034036a32201ff446d824ddc58aefbc Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 19 Sep 2026 10:34:38 -0400 Subject: [PATCH 3/7] ci: add strict standalone docs workflow (#48) --- .github/workflows/docs.yml | 64 ++++++++++++++++++++++++++++++++++ tests/test_release_workflow.py | 14 +++++++- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..a0aec44 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,64 @@ +name: Docs + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: {} + +concurrency: + group: docs-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + PYTHON_VERSION: "3.12" + UV_NO_SOURCES: "1" + +jobs: + build: + name: Strict Documentation + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + with: + version: "0.10.9" + + - name: Set up Python + id: setup-python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install locked documentation environment + run: uv sync --locked --dev --extra docs --python "${{ steps.setup-python.outputs.python-path }}" + + - name: Record source identity + run: | + echo "ML4T_DOCS_VERSION=$(uv run python -c 'from ml4t.models import __version__; print(__version__)')" >> "$GITHUB_ENV" + echo "ML4T_DOCS_COMMIT=${GITHUB_SHA}" >> "$GITHUB_ENV" + + - name: Build and verify documentation + run: | + uv run mkdocs build --strict + ML4T_DOCS_SITE=site RELEASE_COMMIT="$ML4T_DOCS_COMMIT" \ + RELEASE_VERSION="$ML4T_DOCS_VERSION" \ + uv run python scripts/ci/verify_docs_deployment.py + + - name: Upload rendered documentation + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: docs-${{ github.sha }} + path: site/ + if-no-files-found: error + retention-days: 14 diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index 2f13419..9544422 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -246,12 +246,24 @@ def test_release_workflow_reuses_one_commit_bound_candidate() -> None: def test_only_release_workflow_can_deploy_documentation() -> None: - for name in ("ci.yml", "ecosystem.yml"): + for name in ("ci.yml", "docs.yml", "ecosystem.yml"): assert "push-to-another-repository" not in ( ROOT / ".github" / "workflows" / name ).read_text(encoding="utf-8") +def test_standalone_docs_workflow_is_read_only_and_verifies_strict_build() -> None: + workflow = _workflow("docs.yml") + build = workflow["jobs"]["build"] + commands = "\n".join(step.get("run", "") for step in build["steps"]) + + assert workflow["permissions"] == {} + assert build["permissions"] == {"contents": "read"} + assert "uv run mkdocs build --strict" in commands + assert "ML4T_DOCS_SITE=site" in commands + assert "scripts/ci/verify_docs_deployment.py" in commands + + def test_ci_uses_locked_dependencies_and_requires_cuda_for_releases() -> None: workflows = [ (ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8") From f34a79020556abb6ec94ceab18c44ae3ce7123d2 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 19 Sep 2026 10:35:50 -0400 Subject: [PATCH 4/7] ci: set read-only docs workflow permissions (#48) --- .github/workflows/docs.yml | 5 ++--- tests/test_release_workflow.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a0aec44..b8c044c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,7 +7,8 @@ on: branches: [main] workflow_dispatch: -permissions: {} +permissions: + contents: read concurrency: group: docs-${{ github.workflow }}-${{ github.ref }} @@ -21,8 +22,6 @@ jobs: build: name: Strict Documentation runs-on: ubuntu-latest - permissions: - contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index 9544422..464c687 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -257,8 +257,8 @@ def test_standalone_docs_workflow_is_read_only_and_verifies_strict_build() -> No build = workflow["jobs"]["build"] commands = "\n".join(step.get("run", "") for step in build["steps"]) - assert workflow["permissions"] == {} - assert build["permissions"] == {"contents": "read"} + assert workflow["permissions"] == {"contents": "read"} + assert "permissions" not in build assert "uv run mkdocs build --strict" in commands assert "ML4T_DOCS_SITE=site" in commands assert "scripts/ci/verify_docs_deployment.py" in commands From d9f4d4c1c1d598509afac83923d4e0dc54d50c20 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 19 Sep 2026 10:38:09 -0400 Subject: [PATCH 5/7] test: provision docs extra for identity build (#48) --- tests/test_release_workflow.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index 464c687..113f5b7 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -185,7 +185,17 @@ def test_rendered_docs_expose_exact_release_identity(tmp_path: Path) -> None: "ML4T_DOCS_VERSION": expected["version"], } subprocess.run( - ["uv", "run", "mkdocs", "build", "--strict", "--site-dir", str(tmp_path)], + [ + "uv", + "run", + "--extra", + "docs", + "mkdocs", + "build", + "--strict", + "--site-dir", + str(tmp_path), + ], cwd=ROOT, env=environment, check=True, From 7dc5309457923b56de2cf2856837b8bb22d9d980 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 19 Sep 2026 10:39:06 -0400 Subject: [PATCH 6/7] test: keep docs extras out of core qualification (#48) --- tests/test_release_workflow.py | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index 113f5b7..f7e97f4 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -177,32 +177,17 @@ def read_identity(_url: str, _commit: str, attempt: int) -> object: assert sleeps == [10] * 23 -def test_rendered_docs_expose_exact_release_identity(tmp_path: Path) -> None: +def test_rendered_docs_verifier_requires_exact_release_identity(tmp_path: Path) -> None: expected = {"commit": COMMIT, "library": "models", "version": __version__} - environment = { - **os.environ, - "ML4T_DOCS_COMMIT": expected["commit"], - "ML4T_DOCS_VERSION": expected["version"], - } - subprocess.run( - [ - "uv", - "run", - "--extra", - "docs", - "mkdocs", - "build", - "--strict", - "--site-dir", - str(tmp_path), - ], - cwd=ROOT, - env=environment, - check=True, + index = tmp_path / "index.html" + index.write_text( + '' + f'' + f'', + encoding="utf-8", ) verify_docs_deployment.verify_site(tmp_path, expected) - index = tmp_path / "index.html" html = index.read_text(encoding="utf-8").replace( f'', '', From 1e60c148d5e955cdb920d3ecbda55a121cc790d5 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 19 Sep 2026 10:43:29 -0400 Subject: [PATCH 7/7] docs: publish contributor gates and project links (#48) --- README.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 68b66ba..1fda9fd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Finance-native model implementations for latent-factor estimation, stochastic discount factor learning, direct asset prediction, and end-to-end portfolio learning. -Documentation: https://ml4trading.io/docs/models/ +Documentation: [ml4trading.io/docs/models](https://www.ml4trading.io/docs/models/) ## Part of the ML4T Library Ecosystem @@ -213,3 +213,26 @@ Portfolio models learn allocations directly: - [Architecture](docs/reference/architecture.md) - [API Reference](docs/api/index.md) - [Book Guide](docs/book-guide/index.md) + +## Development + +Install the locked development environment, then run the repository gates before opening a pull +request: + +```bash +uv sync --locked --dev --extra docs +uv run ruff check src/ tests/ examples/ scripts/ +uv run ruff format --check src/ tests/ examples/ scripts/ +uv run ty check +uv run pytest tests/ -q --cov-report=json:coverage.json +uv run python scripts/ci/check_coverage.py coverage.json +uv run mkdocs build --strict +uv build +``` + +## Project Links + +- [Documentation](https://www.ml4trading.io/docs/models/) +- [Issues](https://github.com/ml4t/models/issues) +- [Releases](https://github.com/ml4t/models/releases) +- [License](LICENSE)