From 3ee82ac944e4be00065813bd24feed65177a213e Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Tue, 14 Jul 2026 16:34:45 -0300 Subject: [PATCH 1/4] fix: make Fern release publishing resilient --- .github/workflows/build-fern-docs.yml | 60 ++++++++++++++----- .github/workflows/build-notebooks.yml | 44 ++++++++------ .github/workflows/publish-fern-devnotes.yml | 2 +- docs/scripts/build_notebooks_cached.sh | 38 +++++++++++- .../src/data_designer/engine/readiness.py | 6 ++ .../tests/engine/test_readiness.py | 18 ++++++ .../tests/docs/test_fern_published_branch.py | 31 ++++++++++ 7 files changed, 163 insertions(+), 36 deletions(-) diff --git a/.github/workflows/build-fern-docs.yml b/.github/workflows/build-fern-docs.yml index 0a5ee765f..6c86e430b 100644 --- a/.github/workflows/build-fern-docs.yml +++ b/.github/workflows/build-fern-docs.yml @@ -73,12 +73,9 @@ jobs: prepare-published-release: if: needs.resolve-release.outputs.prepare_release_docs == '1' - needs: - - resolve-release - - build-notebooks + needs: resolve-release runs-on: ubuntu-latest permissions: - actions: read contents: write steps: - name: Checkout workflow @@ -94,6 +91,12 @@ jobs: path: source ref: ${{ needs.resolve-release.outputs.source_ref }} + - name: Checkout rolling source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: rolling-source + ref: ${{ github.event.repository.default_branch }} + - name: Checkout published branch uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -130,8 +133,14 @@ jobs: python3 workflow/fern/scripts/fern-release-version.py --root website/fern prepare --version "$RELEASE_TAG" --force python3 workflow/fern/scripts/fern-release-version.py --root website/fern check --version "$RELEASE_TAG" --require-latest-matches-release + - name: Restore rolling Dev Notes and Recipes + run: | + python3 workflow/fern/scripts/fern-published-branch.py patch-devnotes \ + --source-root rolling-source \ + --published-root website + - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: version: "0.9.5" @@ -142,18 +151,13 @@ jobs: working-directory: website run: uv sync --python 3.13 --all-packages --group docs --group notebooks - - name: Download executed notebooks - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: notebooks - path: website/docs/notebooks - - name: Check Fern docs working-directory: website run: make check-fern-docs - name: Commit published branch env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} SOURCE_REF: ${{ needs.resolve-release.outputs.source_ref }} SOURCE_REPOSITORY: ${{ github.repository }} @@ -165,6 +169,7 @@ jobs: fi source_sha=$(git -C ../source rev-parse HEAD) + rolling_sha=$(git -C ../rolling-source rev-parse HEAD) previous_published_sha=$(git rev-parse HEAD) git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" @@ -172,6 +177,7 @@ jobs: git commit \ -m "docs: publish Fern docs for $RELEASE_TAG" \ -m "Source: $SOURCE_REPOSITORY@$SOURCE_REF ($source_sha)" \ + -m "Rolling Dev Notes and Recipes: $SOURCE_REPOSITORY@$DEFAULT_BRANCH ($rolling_sha)" \ -m "Previous docs-website head: $previous_published_sha" git push origin HEAD:"$FERN_PUBLISHED_BRANCH" @@ -182,12 +188,12 @@ jobs: actions: read contents: write with: - use_cache: ${{ github.event_name == 'workflow_dispatch' && inputs.use_cache || false }} + use_cache: ${{ github.event_name == 'release' || inputs.use_cache }} checkout_ref: ${{ needs.resolve-release.outputs.prepare_release_docs == '1' && needs.resolve-release.outputs.source_ref || needs.resolve-release.outputs.release_tag }} secrets: inherit publish: - if: always() && needs.resolve-release.result == 'success' && needs.build-notebooks.result == 'success' && (needs.prepare-published-release.result == 'success' || needs.prepare-published-release.result == 'skipped') + if: always() && needs.resolve-release.result == 'success' && (needs.prepare-published-release.result == 'success' || needs.prepare-published-release.result == 'skipped') needs: - resolve-release - prepare-published-release @@ -204,7 +210,7 @@ jobs: ref: ${{ env.FERN_PUBLISHED_BRANCH }} - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: version: "0.9.5" @@ -215,11 +221,16 @@ jobs: run: uv sync --python 3.13 --all-packages --group docs --group notebooks - name: Download executed notebooks + if: needs.build-notebooks.result == 'success' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: notebooks path: docs/notebooks + - name: Report notebook fallback + if: needs.build-notebooks.result != 'success' + run: echo "::warning::Notebook execution failed; publishing source notebooks without executed outputs." + - name: Check Fern docs run: make check-fern-docs @@ -238,3 +249,24 @@ jobs: fi npx -y "fern-api@$fern_version" generate --docs --no-prompt + + - name: Verify published docs + env: + PREPARE_RELEASE_DOCS: ${{ needs.resolve-release.outputs.prepare_release_docs }} + RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} + run: | + urls=("https://docs.nvidia.com/nemo/datadesigner/latest/getting-started/welcome") + if [ "$PREPARE_RELEASE_DOCS" = "1" ]; then + release_slug="${RELEASE_TAG#refs/tags/}" + if [[ "$release_slug" != v* ]]; then + release_slug="v$release_slug" + fi + urls+=("https://docs.nvidia.com/nemo/datadesigner/$release_slug/getting-started/welcome") + fi + + for url in "${urls[@]}"; do + curl --fail --location --silent --show-error \ + --retry 12 --retry-all-errors --retry-delay 10 --retry-max-time 180 \ + "$url" >/dev/null + echo "Verified $url" + done diff --git a/.github/workflows/build-notebooks.yml b/.github/workflows/build-notebooks.yml index 442689b47..80c79e526 100644 --- a/.github/workflows/build-notebooks.yml +++ b/.github/workflows/build-notebooks.yml @@ -26,15 +26,20 @@ jobs: actions: read contents: write env: + DATA_DESIGNER_SKIP_MODEL_HEALTH_CHECKS: "1" NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} + NOTEBOOK_EXECUTION_ATTEMPTS: "3" + NOTEBOOK_RETRY_DELAY_SECONDS: "15" OPENROUTER_API_KEY: ${{ secrets.TEST_OPENROUTER_API_KEY }} steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.checkout_ref || github.ref }} + - name: Compute notebook cache context + run: echo "NOTEBOOK_CACHE_CONTEXT=${{ hashFiles('uv.lock', 'packages/data-designer-config/src/**/*.py', 'packages/data-designer-engine/src/**/*.py', 'packages/data-designer/src/**/*.py') }}" >> "$GITHUB_ENV" - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: version: "0.9.5" - name: Set up Python @@ -47,7 +52,7 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .notebook-cache - key: notebooks-${{ hashFiles('docs/notebook_source/*.py') }} + key: notebooks-${{ hashFiles('docs/notebook_source/*.py', 'uv.lock', 'packages/data-designer-config/src/**/*.py', 'packages/data-designer-engine/src/**/*.py', 'packages/data-designer/src/**/*.py') }} restore-keys: | notebooks- - name: Seed cache from last successful artifact @@ -55,32 +60,30 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Only seed when the cache dir is truly empty (no partial restore). - # A partial restore already has correct per-file hashes — seeding - # over it would write current hashes for old notebooks, masking changes. - if [ -d .notebook-cache ] && [ -n "$(ls -A .notebook-cache 2>/dev/null)" ]; then - echo "Partial cache restored — skipping artifact seed" - exit 0 - fi - echo "Empty cache — seeding from last successful build artifact..." + echo "Seeding cache from the last verified build artifact..." LAST_RUN_ID=$(gh run list --workflow build-notebooks.yml --status success --branch main --limit 1 --json databaseId -q '.[0].databaseId // empty' 2>/dev/null || true) if [ -n "$LAST_RUN_ID" ]; then SEED_TMPDIR=$(mktemp -d) - if gh run download "$LAST_RUN_ID" --name notebooks --dir "$SEED_TMPDIR" 2>/dev/null; then + if gh run download "$LAST_RUN_ID" --name notebooks --dir "$SEED_TMPDIR/notebooks" 2>/dev/null \ + && gh run download "$LAST_RUN_ID" --name notebook-source-hashes --dir "$SEED_TMPDIR/hashes" 2>/dev/null; then mkdir -p .notebook-cache for src in docs/notebook_source/*.py; do name="$(basename "$src" .py)" - nb="$SEED_TMPDIR/${name}.ipynb" - if [ -f "$nb" ]; then - hash="$(sha256sum "$src" | cut -d' ' -f1)" + nb="$SEED_TMPDIR/notebooks/${name}.ipynb" + hash_file="$SEED_TMPDIR/hashes/${name}.sha256" + source_hash="$(sha256sum "$src" | cut -d' ' -f1)" + fingerprint="${source_hash}:${NOTEBOOK_CACHE_CONTEXT}" + if [ -f "$nb" ] && [ -f "$hash_file" ] && [ "$(cat "$hash_file")" = "$fingerprint" ]; then cp "$nb" ".notebook-cache/${name}.ipynb" - echo "$hash" > ".notebook-cache/${name}.sha256" + echo "$fingerprint" > ".notebook-cache/${name}.sha256" echo " Seeded: ${name}" + else + echo " Skipped: ${name} (source or environment changed)" fi done echo "Cache seeded from run $LAST_RUN_ID" else - echo "Could not download artifact from run $LAST_RUN_ID, proceeding without cache" + echo "Could not download verified artifacts from run $LAST_RUN_ID, proceeding without cache" fi rm -rf "$SEED_TMPDIR" else @@ -89,12 +92,17 @@ jobs: - name: Convert and execute notebooks run: | if [ "$GITHUB_EVENT_NAME" = "schedule" ]; then - export DATA_DESIGNER_SKIP_NVIDIA_VISION_HEALTH_CHECK=1 export DATA_DESIGNER_FLUX_2_PRO_CREATE_NUM_RECORDS=2 fi - make convert-execute-notebooks ${{ inputs.use_cache && 'USE_CACHE=1' || '' }} + make convert-execute-notebooks USE_CACHE=1 - name: Upload notebooks as artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: notebooks path: docs/notebooks + - name: Upload notebook source hashes + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: notebook-source-hashes + path: .notebook-cache/*.sha256 + include-hidden-files: true diff --git a/.github/workflows/publish-fern-devnotes.yml b/.github/workflows/publish-fern-devnotes.yml index 9bbac57a7..ffda444b1 100644 --- a/.github/workflows/publish-fern-devnotes.yml +++ b/.github/workflows/publish-fern-devnotes.yml @@ -86,7 +86,7 @@ jobs: --metadata-published-branch "$PUBLISHED_BRANCH" - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 with: version: "0.9.5" diff --git a/docs/scripts/build_notebooks_cached.sh b/docs/scripts/build_notebooks_cached.sh index 8a505fbfa..74164ca6d 100755 --- a/docs/scripts/build_notebooks_cached.sh +++ b/docs/scripts/build_notebooks_cached.sh @@ -3,7 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 # Build notebooks with per-file caching. Only re-executes notebooks whose -# source .py file changed since the last cached build. +# source or runtime context changed since the last cached build. # # Usage: # ./docs/scripts/build_notebooks_cached.sh [CACHE_DIR] @@ -25,6 +25,17 @@ SOURCE_DIR="$REPO_ROOT/docs/notebook_source" OUTPUT_DIR="$REPO_ROOT/docs/notebooks" CACHE_DIR="${1:-$REPO_ROOT/.notebook-cache}" DOCS_JUPYTEXT="${DOCS_JUPYTEXT:-$REPO_ROOT/.venv/bin/jupytext}" +NOTEBOOK_EXECUTION_ATTEMPTS="${NOTEBOOK_EXECUTION_ATTEMPTS:-1}" +NOTEBOOK_RETRY_DELAY_SECONDS="${NOTEBOOK_RETRY_DELAY_SECONDS:-15}" + +if [[ ! "$NOTEBOOK_EXECUTION_ATTEMPTS" =~ ^[1-9][0-9]*$ ]]; then + echo "❌ NOTEBOOK_EXECUTION_ATTEMPTS must be a positive integer" + exit 1 +fi +if [[ ! "$NOTEBOOK_RETRY_DELAY_SECONDS" =~ ^[0-9]+$ ]]; then + echo "❌ NOTEBOOK_RETRY_DELAY_SECONDS must be a non-negative integer" + exit 1 +fi if [ ! -x "$DOCS_JUPYTEXT" ]; then echo "❌ Missing jupytext executable: $DOCS_JUPYTEXT" @@ -32,6 +43,27 @@ if [ ! -x "$DOCS_JUPYTEXT" ]; then exit 1 fi +execute_notebook() { + local src="$1" + local output="${src%.py}.ipynb" + local attempt + local delay + + for ((attempt = 1; attempt <= NOTEBOOK_EXECUTION_ATTEMPTS; attempt++)); do + rm -f "$output" + if "$DOCS_JUPYTEXT" --to ipynb --execute "$src"; then + return 0 + fi + rm -f "$output" + if [ "$attempt" -eq "$NOTEBOOK_EXECUTION_ATTEMPTS" ]; then + return 1 + fi + delay=$((NOTEBOOK_RETRY_DELAY_SECONDS * attempt)) + echo " ⚠️ Attempt $attempt failed; retrying in ${delay}s" + sleep "$delay" + done +} + rm -rf "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR" "$CACHE_DIR" @@ -43,7 +75,7 @@ needs_cleanup=false for src in "$SOURCE_DIR"/*.py; do name="$(basename "$src" .py)" - hash="$(compute_sha256 "$src")" + hash="$(compute_sha256 "$src"):${NOTEBOOK_CACHE_CONTEXT:-}" cached_hash_file="$CACHE_DIR/${name}.sha256" cached_notebook="$CACHE_DIR/${name}.ipynb" @@ -52,7 +84,7 @@ for src in "$SOURCE_DIR"/*.py; do cp "$cached_notebook" "$OUTPUT_DIR/${name}.ipynb" else echo " 🔄 $name.ipynb - executing..." - "$DOCS_JUPYTEXT" --to ipynb --execute "$src" + execute_notebook "$src" mv "$SOURCE_DIR/${name}.ipynb" "$OUTPUT_DIR/${name}.ipynb" needs_cleanup=true diff --git a/packages/data-designer-engine/src/data_designer/engine/readiness.py b/packages/data-designer-engine/src/data_designer/engine/readiness.py index c8319976f..f195dc46b 100644 --- a/packages/data-designer-engine/src/data_designer/engine/readiness.py +++ b/packages/data-designer-engine/src/data_designer/engine/readiness.py @@ -23,6 +23,7 @@ import asyncio import logging +import os from collections.abc import Sequence from typing import TYPE_CHECKING @@ -38,6 +39,7 @@ # Match the timeout the dataset builder's startup gate has always used. _MODEL_HEALTH_CHECK_TIMEOUT_SECONDS = 180 +_SKIP_MODEL_HEALTH_CHECKS_ENV = "DATA_DESIGNER_SKIP_MODEL_HEALTH_CHECKS" def run_readiness_check( @@ -74,6 +76,10 @@ def _run_model_health_check( column_configs: Sequence[ColumnConfigT], resource_provider: ResourceProvider, ) -> None: + if os.environ.get(_SKIP_MODEL_HEALTH_CHECKS_ENV) == "1": + logger.info("Skipping model health checks because %s=1", _SKIP_MODEL_HEALTH_CHECKS_ENV) + return + model_aliases: set[str] = set() for config in column_configs: model_aliases.update(config.get_model_aliases()) diff --git a/packages/data-designer-engine/tests/engine/test_readiness.py b/packages/data-designer-engine/tests/engine/test_readiness.py index b070c1e5d..7c0dbe193 100644 --- a/packages/data-designer-engine/tests/engine/test_readiness.py +++ b/packages/data-designer-engine/tests/engine/test_readiness.py @@ -103,6 +103,24 @@ def test_run_readiness_check_skips_model_probe_when_no_aliases( stub_resource_provider.model_registry.arun_health_check.assert_not_called() +def test_run_readiness_check_skips_model_probe_from_environment( + monkeypatch, + stub_resource_provider, + stub_model_configs, + mock_async_readiness, +) -> None: + stub_resource_provider.model_registry.arun_health_check = Mock() + stub_resource_provider.mcp_registry = None + monkeypatch.setenv("DATA_DESIGNER_SKIP_MODEL_HEALTH_CHECKS", "1") + columns = _build_columns(model_configs=stub_model_configs, llm_columns=[("col", "stub-text")]) + + run_readiness_check(columns, stub_resource_provider) + + _, mock_submit = mock_async_readiness + stub_resource_provider.model_registry.arun_health_check.assert_not_called() + mock_submit.assert_not_called() + + def test_run_readiness_check_propagates_model_probe_error( stub_resource_provider, stub_model_configs, diff --git a/packages/data-designer/tests/docs/test_fern_published_branch.py b/packages/data-designer/tests/docs/test_fern_published_branch.py index 43f57bddb..8e7e425e1 100644 --- a/packages/data-designer/tests/docs/test_fern_published_branch.py +++ b/packages/data-designer/tests/docs/test_fern_published_branch.py @@ -108,6 +108,10 @@ def test_patch_devnotes_syncs_root_config_and_preserves_published_versions(tmp_p """, ) write_text(published_root / "fern" / "fern.config.json", '{"organization": "nvidia", "version": "4.106.0"}\n') + write_text( + published_root / "fern" / "publish-metadata.json", + '{"action": "release-snapshot", "release_tag": "v0.6.0"}\n', + ) write_text(published_root / "fern" / "assets" / "published-only-asset.png", "old asset") write_text( published_root / "fern" / "versions" / "latest.yml", @@ -126,6 +130,23 @@ def test_patch_devnotes_syncs_root_config_and_preserves_published_versions(tmp_p path: ./v0.6.0/pages/concepts/released-concept.mdx """, ) + write_text( + published_root / "fern" / "versions" / "v0.6.0.yml", + """navigation: + - section: Recipes + contents: + - page: Released Recipe + path: ./v0.6.0/pages/recipes/released-recipe.mdx + - section: Dev Notes + contents: + - page: Released Note + path: ./v0.6.0/pages/devnotes/posts/released-note.mdx +""", + ) + write_text( + published_root / "fern" / "versions" / "v0.6.0" / "pages" / "devnotes" / "posts" / "released-note.mdx", + "# Released", + ) write_text(published_root / "fern" / "versions" / "latest" / "pages" / "recipes" / "old-recipe.mdx", "# Old") assert module.patch_devnotes(patch_args(source_root, published_root)) == 0 @@ -154,6 +175,9 @@ def test_patch_devnotes_syncs_root_config_and_preserves_published_versions(tmp_p 'import type { ImageExample } from "./ImageExample";\nexport type ImageGallery = ImageExample[];\n' ) assert not (published_root / "fern" / "assets" / "published-only-asset.png").exists() + assert (published_root / "fern" / "publish-metadata.json").read_text() == ( + '{"action": "release-snapshot", "release_tag": "v0.6.0"}\n' + ) assert (published_root / "fern" / "versions" / "latest" / "pages" / "devnotes" / "posts" / "new-note.mdx").exists() published_nav = (published_root / "fern" / "versions" / "latest.yml").read_text() assert published_nav.count("section: Recipes") == 1 @@ -162,3 +186,10 @@ def test_patch_devnotes_syncs_root_config_and_preserves_published_versions(tmp_p assert "path: ./v0.6.0/pages/concepts/released-concept.mdx" in published_nav assert (published_root / "fern" / "versions" / "latest" / "pages" / "recipes" / "new-recipe.mdx").exists() assert not (published_root / "fern" / "versions" / "latest" / "pages" / "recipes" / "old-recipe.mdx").exists() + assert ( + "./v0.6.0/pages/devnotes/posts/released-note.mdx" + in (published_root / "fern" / "versions" / "v0.6.0.yml").read_text() + ) + assert ( + published_root / "fern" / "versions" / "v0.6.0" / "pages" / "devnotes" / "posts" / "released-note.mdx" + ).read_text() == "# Released" From f57646221c77a09551251656fc619e91fa894d82 Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Tue, 14 Jul 2026 16:41:37 -0300 Subject: [PATCH 2/4] fix: restore working setup-uv action --- .github/workflows/build-fern-docs.yml | 4 ++-- .github/workflows/build-notebooks.yml | 2 +- .github/workflows/publish-fern-devnotes.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-fern-docs.yml b/.github/workflows/build-fern-docs.yml index 6c86e430b..7bed8fe35 100644 --- a/.github/workflows/build-fern-docs.yml +++ b/.github/workflows/build-fern-docs.yml @@ -140,7 +140,7 @@ jobs: --published-root website - name: Install uv - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: version: "0.9.5" @@ -210,7 +210,7 @@ jobs: ref: ${{ env.FERN_PUBLISHED_BRANCH }} - name: Install uv - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: version: "0.9.5" diff --git a/.github/workflows/build-notebooks.yml b/.github/workflows/build-notebooks.yml index 80c79e526..0c33fd8bb 100644 --- a/.github/workflows/build-notebooks.yml +++ b/.github/workflows/build-notebooks.yml @@ -39,7 +39,7 @@ jobs: - name: Compute notebook cache context run: echo "NOTEBOOK_CACHE_CONTEXT=${{ hashFiles('uv.lock', 'packages/data-designer-config/src/**/*.py', 'packages/data-designer-engine/src/**/*.py', 'packages/data-designer/src/**/*.py') }}" >> "$GITHUB_ENV" - name: Install uv - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: version: "0.9.5" - name: Set up Python diff --git a/.github/workflows/publish-fern-devnotes.yml b/.github/workflows/publish-fern-devnotes.yml index ffda444b1..f772ff8b2 100644 --- a/.github/workflows/publish-fern-devnotes.yml +++ b/.github/workflows/publish-fern-devnotes.yml @@ -86,7 +86,7 @@ jobs: --metadata-published-branch "$PUBLISHED_BRANCH" - name: Install uv - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: version: "0.9.5" From bd441cc51e34d02f1320c41efd7ab7434cda974c Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Thu, 16 Jul 2026 18:09:16 -0300 Subject: [PATCH 3/4] fix: preserve Fern notebook snapshots --- .github/workflows/build-fern-docs.yml | 90 +++++++++++++- .github/workflows/build-notebooks.yml | 107 +++++++++++----- .github/workflows/publish-fern-devnotes.yml | 48 ++++---- Makefile | 6 +- docs/scripts/fern_notebook_snapshot.sh | 115 ++++++++++++++++++ .../tests/docs/test_docs_workflows.py | 62 ++++++++++ .../tests/docs/test_fern_notebook_snapshot.py | 71 +++++++++++ .../tests/docs/test_fern_published_branch.py | 7 ++ 8 files changed, 440 insertions(+), 66 deletions(-) create mode 100755 docs/scripts/fern_notebook_snapshot.sh create mode 100644 packages/data-designer/tests/docs/test_docs_workflows.py create mode 100644 packages/data-designer/tests/docs/test_fern_notebook_snapshot.py diff --git a/.github/workflows/build-fern-docs.yml b/.github/workflows/build-fern-docs.yml index 7bed8fe35..c1d59344c 100644 --- a/.github/workflows/build-fern-docs.yml +++ b/.github/workflows/build-fern-docs.yml @@ -155,6 +155,18 @@ jobs: working-directory: website run: make check-fern-docs + - name: Publish source notebook snapshot + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} + SOURCE_REPOSITORY: ${{ github.repository }} + working-directory: website + run: | + archive="$RUNNER_TEMP/fern-notebooks-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-source-fallback.tar.gz" + bash ../workflow/docs/scripts/fern_notebook_snapshot.sh \ + create . "$RELEASE_TAG" source-fallback "$archive" + gh release upload "$RELEASE_TAG" "$archive" --repo "$SOURCE_REPOSITORY" + - name: Commit published branch env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} @@ -193,20 +205,28 @@ jobs: secrets: inherit publish: - if: always() && needs.resolve-release.result == 'success' && (needs.prepare-published-release.result == 'success' || needs.prepare-published-release.result == 'skipped') + if: always() && needs.resolve-release.result == 'success' && (needs.prepare-published-release.result == 'success' || needs.prepare-published-release.result == 'skipped') && (needs.build-notebooks.result == 'success' || needs.build-notebooks.result == 'failure') needs: - resolve-release - prepare-published-release - build-notebooks runs-on: ubuntu-latest permissions: - contents: read + contents: write env: FERN_TOKEN: ${{ secrets.DOCS_FERN_TOKEN }} steps: + - name: Checkout workflow + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: workflow + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || github.event.repository.default_branch }} + - name: Checkout published branch uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + fetch-depth: 0 + path: website ref: ${{ env.FERN_PUBLISHED_BRANCH }} - name: Install uv @@ -218,6 +238,7 @@ jobs: run: uv python install 3.13 - name: Install docs dependencies + working-directory: website run: uv sync --python 3.13 --all-packages --group docs --group notebooks - name: Download executed notebooks @@ -225,16 +246,73 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: notebooks - path: docs/notebooks + path: website/docs/notebooks + + - name: Restore prepared notebook snapshot + if: needs.build-notebooks.result == 'failure' + env: + GH_TOKEN: ${{ github.token }} + SOURCE_REPOSITORY: ${{ github.repository }} + working-directory: website + run: | + release_tag=$(jq -er '.release_tag' fern/notebook-snapshot.json) + asset=$(jq -er '.asset' fern/notebook-snapshot.json) + snapshot_dir="$RUNNER_TEMP/fern-notebook-snapshot" + mkdir -p "$snapshot_dir" + gh release download "$release_tag" --repo "$SOURCE_REPOSITORY" \ + --pattern "$asset" --dir "$snapshot_dir" + bash ../workflow/docs/scripts/fern_notebook_snapshot.sh \ + restore . "$snapshot_dir/$asset" - name: Report notebook fallback - if: needs.build-notebooks.result != 'success' - run: echo "::warning::Notebook execution failed; publishing source notebooks without executed outputs." + if: needs.build-notebooks.result == 'failure' + run: echo "::warning::Notebook execution failed; publishing the prepared source notebook snapshot." - - name: Check Fern docs + - name: Check Fern docs with executed notebooks + if: needs.build-notebooks.result == 'success' + working-directory: website run: make check-fern-docs + - name: Check Fern docs with prepared notebooks + if: needs.build-notebooks.result == 'failure' + working-directory: website + run: make check-fern-published-docs + + - name: Publish executed notebook snapshot + if: needs.build-notebooks.result == 'success' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} + SOURCE_REPOSITORY: ${{ github.repository }} + working-directory: website + run: | + archive="$RUNNER_TEMP/fern-notebooks-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-executed.tar.gz" + bash ../workflow/docs/scripts/fern_notebook_snapshot.sh \ + create . "$RELEASE_TAG" executed "$archive" + gh release upload "$RELEASE_TAG" "$archive" --repo "$SOURCE_REPOSITORY" + + - name: Commit executed notebook snapshot + if: needs.build-notebooks.result == 'success' + env: + RELEASE_TAG: ${{ needs.resolve-release.outputs.release_tag }} + SOURCE_REPOSITORY: ${{ github.repository }} + working-directory: website + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add fern/notebook-snapshot.json + if git diff --cached --quiet; then + echo "::notice::Fern notebook snapshot is already current for $RELEASE_TAG." + exit 0 + fi + git commit \ + -m "docs: update Fern notebooks for $RELEASE_TAG" \ + -m "Source: $SOURCE_REPOSITORY@$RELEASE_TAG" \ + -m "Snapshot mode: executed" + git push origin HEAD:"$FERN_PUBLISHED_BRANCH" + - name: Publish Fern docs + working-directory: website run: | if [ -z "$FERN_TOKEN" ]; then echo "::error::DOCS_FERN_TOKEN secret is required to publish Fern docs." diff --git a/.github/workflows/build-notebooks.yml b/.github/workflows/build-notebooks.yml index 0c33fd8bb..4a810b76e 100644 --- a/.github/workflows/build-notebooks.yml +++ b/.github/workflows/build-notebooks.yml @@ -26,8 +26,11 @@ jobs: actions: read contents: write env: + DATA_DESIGNER_FLUX_2_PRO_CREATE_NUM_RECORDS: ${{ github.event_name == 'schedule' && '2' || '5' }} DATA_DESIGNER_SKIP_MODEL_HEALTH_CHECKS: "1" NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} + NOTEBOOK_CACHE_ENABLED: ${{ inputs.use_cache && '1' || '0' }} + NOTEBOOK_EXECUTION_PROFILE: ${{ github.event_name == 'schedule' && 'flux-2-pro-2-records' || 'flux-2-pro-5-records' }} NOTEBOOK_EXECUTION_ATTEMPTS: "3" NOTEBOOK_RETRY_DELAY_SECONDS: "15" OPENROUTER_API_KEY: ${{ secrets.TEST_OPENROUTER_API_KEY }} @@ -37,7 +40,7 @@ jobs: with: ref: ${{ inputs.checkout_ref || github.ref }} - name: Compute notebook cache context - run: echo "NOTEBOOK_CACHE_CONTEXT=${{ hashFiles('uv.lock', 'packages/data-designer-config/src/**/*.py', 'packages/data-designer-engine/src/**/*.py', 'packages/data-designer/src/**/*.py') }}" >> "$GITHUB_ENV" + run: echo "NOTEBOOK_CACHE_CONTEXT=${NOTEBOOK_EXECUTION_PROFILE}:${{ hashFiles('Makefile', 'docs/scripts/build_notebooks_cached.sh', 'uv.lock', 'packages/data-designer-config/src/**/*.py', 'packages/data-designer-engine/src/**/*.py', 'packages/data-designer/src/**/*.py') }}" >> "$GITHUB_ENV" - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: @@ -52,47 +55,87 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .notebook-cache - key: notebooks-${{ hashFiles('docs/notebook_source/*.py', 'uv.lock', 'packages/data-designer-config/src/**/*.py', 'packages/data-designer-engine/src/**/*.py', 'packages/data-designer/src/**/*.py') }} + key: notebooks-${{ env.NOTEBOOK_EXECUTION_PROFILE }}-${{ hashFiles('docs/notebook_source/*.py', 'Makefile', 'docs/scripts/build_notebooks_cached.sh', 'uv.lock', 'packages/data-designer-config/src/**/*.py', 'packages/data-designer-engine/src/**/*.py', 'packages/data-designer/src/**/*.py') }} restore-keys: | - notebooks- + notebooks-${{ env.NOTEBOOK_EXECUTION_PROFILE }}- - name: Seed cache from last successful artifact if: inputs.use_cache && steps.cache.outputs.cache-hit != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - echo "Seeding cache from the last verified build artifact..." - LAST_RUN_ID=$(gh run list --workflow build-notebooks.yml --status success --branch main --limit 1 --json databaseId -q '.[0].databaseId // empty' 2>/dev/null || true) - if [ -n "$LAST_RUN_ID" ]; then - SEED_TMPDIR=$(mktemp -d) - if gh run download "$LAST_RUN_ID" --name notebooks --dir "$SEED_TMPDIR/notebooks" 2>/dev/null \ - && gh run download "$LAST_RUN_ID" --name notebook-source-hashes --dir "$SEED_TMPDIR/hashes" 2>/dev/null; then - mkdir -p .notebook-cache - for src in docs/notebook_source/*.py; do - name="$(basename "$src" .py)" - nb="$SEED_TMPDIR/notebooks/${name}.ipynb" - hash_file="$SEED_TMPDIR/hashes/${name}.sha256" - source_hash="$(sha256sum "$src" | cut -d' ' -f1)" - fingerprint="${source_hash}:${NOTEBOOK_CACHE_CONTEXT}" - if [ -f "$nb" ] && [ -f "$hash_file" ] && [ "$(cat "$hash_file")" = "$fingerprint" ]; then - cp "$nb" ".notebook-cache/${name}.ipynb" - echo "$fingerprint" > ".notebook-cache/${name}.sha256" - echo " Seeded: ${name}" - else - echo " Skipped: ${name} (source or environment changed)" - fi - done - echo "Cache seeded from run $LAST_RUN_ID" - else - echo "Could not download verified artifacts from run $LAST_RUN_ID, proceeding without cache" + fingerprint_for() { + source_hash="$(sha256sum "$1" | cut -d' ' -f1)" + echo "${source_hash}:${NOTEBOOK_CACHE_CONTEXT}" + } + + cache_complete() { + for src in docs/notebook_source/*.py; do + name="$(basename "$src" .py)" + fingerprint="$(fingerprint_for "$src")" + if [ ! -f ".notebook-cache/${name}.ipynb" ] \ + || [ ! -f ".notebook-cache/${name}.sha256" ] \ + || [ "$(cat ".notebook-cache/${name}.sha256")" != "$fingerprint" ]; then + return 1 + fi + done + } + + mkdir -p .notebook-cache + if cache_complete; then + echo "Restored cache already contains every current notebook." + exit 0 + fi + + echo "Seeding cache from verified build artifacts..." + mapfile -t RUN_IDS < <( + { + gh run list --workflow build-fern-docs.yml --status success --limit 10 \ + --json databaseId -q '.[].databaseId' 2>/dev/null || true + gh run list --workflow build-notebooks.yml --status success --branch main --limit 10 \ + --json databaseId -q '.[].databaseId' 2>/dev/null || true + } | awk 'NF && !seen[$0]++' + ) + + SEED_TMPDIR=$(mktemp -d) + trap 'rm -rf "$SEED_TMPDIR"' EXIT + for run_id in "${RUN_IDS[@]}"; do + rm -rf "$SEED_TMPDIR/notebooks" "$SEED_TMPDIR/hashes" + if ! gh run download "$run_id" --name notebooks --dir "$SEED_TMPDIR/notebooks" 2>/dev/null \ + || ! gh run download "$run_id" --name notebook-source-hashes --dir "$SEED_TMPDIR/hashes" 2>/dev/null; then + continue fi - rm -rf "$SEED_TMPDIR" - else - echo "No previous successful run found, proceeding without cache" + + for src in docs/notebook_source/*.py; do + name="$(basename "$src" .py)" + fingerprint="$(fingerprint_for "$src")" + if [ -f ".notebook-cache/${name}.ipynb" ] \ + && [ -f ".notebook-cache/${name}.sha256" ] \ + && [ "$(cat ".notebook-cache/${name}.sha256")" = "$fingerprint" ]; then + continue + fi + + nb="$SEED_TMPDIR/notebooks/${name}.ipynb" + hash_file="$SEED_TMPDIR/hashes/${name}.sha256" + if [ -f "$nb" ] && [ -f "$hash_file" ] && [ "$(cat "$hash_file")" = "$fingerprint" ]; then + cp "$nb" ".notebook-cache/${name}.ipynb" + echo "$fingerprint" > ".notebook-cache/${name}.sha256" + echo " Seeded: ${name} (run $run_id)" + fi + done + + if cache_complete; then + echo "Cache completed from verified artifacts." + break + fi + done + + if ! cache_complete; then + echo "No compatible artifact found for every notebook; unmatched notebooks will execute." fi - name: Convert and execute notebooks run: | - if [ "$GITHUB_EVENT_NAME" = "schedule" ]; then - export DATA_DESIGNER_FLUX_2_PRO_CREATE_NUM_RECORDS=2 + if [ "$NOTEBOOK_CACHE_ENABLED" != "1" ]; then + rm -rf .notebook-cache fi make convert-execute-notebooks USE_CACHE=1 - name: Upload notebooks as artifacts diff --git a/.github/workflows/publish-fern-devnotes.yml b/.github/workflows/publish-fern-devnotes.yml index f772ff8b2..9da763c12 100644 --- a/.github/workflows/publish-fern-devnotes.yml +++ b/.github/workflows/publish-fern-devnotes.yml @@ -37,7 +37,6 @@ jobs: deploy: runs-on: ubuntu-latest permissions: - actions: read contents: write env: FERN_TOKEN: ${{ secrets.DOCS_FERN_TOKEN }} @@ -70,6 +69,14 @@ jobs: git fetch origin "$FERN_PUBLISHED_BRANCH" --depth=1 git checkout -B "$FERN_PUBLISHED_BRANCH" FETCH_HEAD + - name: Require published notebook snapshot + working-directory: website + run: | + if [ ! -f fern/notebook-snapshot.json ]; then + echo "::error::No canonical notebook snapshot is published. Run the Build Fern docs workflow successfully once." + exit 1 + fi + - name: Patch Dev Notes into latest Fern docs env: SOURCE_REF: ${{ github.ref }} @@ -97,37 +104,24 @@ jobs: working-directory: website run: uv sync --python 3.13 --all-packages --group docs --group notebooks - - name: Reuse notebooks from last successful docs build + - name: Restore published notebook snapshot env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} + SOURCE_REPOSITORY: ${{ github.repository }} + working-directory: website run: | - mkdir -p website/docs/notebooks - - download_notebooks() { - workflow="$1" - shift - - last_run_id=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status success "$@" --limit 1 --json databaseId -q '.[0].databaseId // empty' 2>/dev/null || true) - if [ -n "$last_run_id" ] && gh run download "$last_run_id" --repo "$GITHUB_REPOSITORY" --name notebooks --dir website/docs/notebooks; then - echo "::notice::Downloaded notebooks from $workflow run $last_run_id" - exit 0 - fi - } - - for workflow in build-fern-docs.yml; do - download_notebooks "$workflow" --event release - done - - for workflow in build-fern-docs.yml; do - download_notebooks "$workflow" --event workflow_dispatch --branch main - done - - echo "::error::No successful docs build run with notebook artifacts found." - exit 1 + release_tag=$(jq -er '.release_tag' fern/notebook-snapshot.json) + asset=$(jq -er '.asset' fern/notebook-snapshot.json) + snapshot_dir="$RUNNER_TEMP/fern-notebook-snapshot" + mkdir -p "$snapshot_dir" + gh release download "$release_tag" --repo "$SOURCE_REPOSITORY" \ + --pattern "$asset" --dir "$snapshot_dir" + bash ../workflow/docs/scripts/fern_notebook_snapshot.sh \ + restore . "$snapshot_dir/$asset" - name: Check Fern docs working-directory: website - run: make check-fern-docs + run: make check-fern-published-docs - name: Commit published branch env: diff --git a/Makefile b/Makefile index e83917146..60cb1bed2 100644 --- a/Makefile +++ b/Makefile @@ -85,6 +85,7 @@ help: @echo " check-fern-release-version VERSION=X.Y.Z - Verify Fern has a version entry for release publishing" @echo " prepare-fern-docs - Generate local Fern artifacts" @echo " check-fern-docs - Generate local Fern artifacts and run fern check" + @echo " check-fern-published-docs - Check Fern docs using the restored notebook snapshot" @echo " check-fern-docs-locally - Install deps, generate Fern artifacts, and run fern check" @echo " serve-fern-docs-locally - Generate local Fern artifacts and serve Fern docs" @echo " check-license-headers - Check if all files have license headers" @@ -503,6 +504,9 @@ prepare-fern-docs: generate-fern-notebooks check-fern-docs: prepare-fern-docs cd fern && $(FERN) check +check-fern-published-docs: + cd fern && $(FERN) check + check-fern-docs-locally: @$(MAKE) install-docs-deps @$(MAKE) check-fern-docs @@ -746,7 +750,7 @@ clean-test-coverage: .PHONY: bench-cli-startup bench-cli-startup-verbose \ build build-config build-engine build-interface \ check-all check-all-fix check-config check-engine check-interface \ - check-fern-docs check-fern-docs-locally check-fern-release-version check-fern-theme-access check-license-headers \ + check-fern-docs check-fern-docs-locally check-fern-published-docs check-fern-release-version check-fern-theme-access check-license-headers \ clean clean-dist clean-notebooks clean-pycache clean-test-coverage \ convert-execute-notebooks \ coverage coverage-config coverage-engine coverage-interface \ diff --git a/docs/scripts/fern_notebook_snapshot.sh b/docs/scripts/fern_notebook_snapshot.sh new file mode 100755 index 000000000..16760c8fc --- /dev/null +++ b/docs/scripts/fern_notebook_snapshot.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +compute_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +create_snapshot() { + local root="$1" + local release_tag="$2" + local mode="$3" + local archive="$4" + local asset_name + local digest + + if [ "$mode" != "executed" ] && [ "$mode" != "source-fallback" ]; then + echo "Invalid notebook snapshot mode: $mode" >&2 + exit 1 + fi + if [ ! -d "$root/fern/components/notebooks" ]; then + echo "Missing rendered notebooks: $root/fern/components/notebooks" >&2 + exit 1 + fi + + asset_name="$(basename "$archive")" + mkdir -p "$(dirname "$archive")" + tar -czf "$archive" -C "$root" fern/components/notebooks + digest="$(compute_sha256 "$archive")" + + jq -n \ + --arg release_tag "$release_tag" \ + --arg asset "$asset_name" \ + --arg sha256 "$digest" \ + --arg mode "$mode" \ + --arg run_id "${GITHUB_RUN_ID:-local}" \ + --arg run_attempt "${GITHUB_RUN_ATTEMPT:-1}" \ + '{ + schema_version: 1, + release_tag: $release_tag, + asset: $asset, + sha256: $sha256, + mode: $mode, + run_id: $run_id, + run_attempt: $run_attempt + }' > "$root/fern/notebook-snapshot.json" +} + +restore_snapshot() { + local root="$1" + local archive="$2" + local metadata="$root/fern/notebook-snapshot.json" + local expected_asset + local expected_digest + local actual_digest + + if [ ! -f "$metadata" ]; then + echo "Missing notebook snapshot metadata: $metadata" >&2 + exit 1 + fi + + expected_asset="$(jq -er '.asset' "$metadata")" + expected_digest="$(jq -er '.sha256' "$metadata")" + if [ "$(basename "$archive")" != "$expected_asset" ]; then + echo "Notebook snapshot asset mismatch: expected $expected_asset" >&2 + exit 1 + fi + + actual_digest="$(compute_sha256 "$archive")" + if [ "$actual_digest" != "$expected_digest" ]; then + echo "Notebook snapshot checksum mismatch" >&2 + exit 1 + fi + if tar -tzf "$archive" | grep -Ev '^fern/components/notebooks(/.*)?$' >/dev/null; then + echo "Notebook snapshot contains unexpected paths" >&2 + exit 1 + fi + + rm -rf "$root/fern/components/notebooks" + tar -xzf "$archive" -C "$root" +} + +if [ "$#" -lt 1 ]; then + echo "Usage: $0 create ROOT RELEASE_TAG MODE ARCHIVE | restore ROOT ARCHIVE" >&2 + exit 1 +fi + +command="$1" +shift +case "$command" in + create) + if [ "$#" -ne 4 ]; then + echo "Usage: $0 create ROOT RELEASE_TAG MODE ARCHIVE" >&2 + exit 1 + fi + create_snapshot "$@" + ;; + restore) + if [ "$#" -ne 2 ]; then + echo "Usage: $0 restore ROOT ARCHIVE" >&2 + exit 1 + fi + restore_snapshot "$@" + ;; + *) + echo "Unknown command: $command" >&2 + exit 1 + ;; +esac diff --git a/packages/data-designer/tests/docs/test_docs_workflows.py b/packages/data-designer/tests/docs/test_docs_workflows.py new file mode 100644 index 000000000..114b50b2e --- /dev/null +++ b/packages/data-designer/tests/docs/test_docs_workflows.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path + +WORKFLOWS_DIR = Path(__file__).resolve().parents[4] / ".github" / "workflows" + + +def test_notebook_cache_is_scoped_to_execution_profile() -> None: + workflow = (WORKFLOWS_DIR / "build-notebooks.yml").read_text() + + assert ( + "DATA_DESIGNER_FLUX_2_PRO_CREATE_NUM_RECORDS: ${{ github.event_name == 'schedule' && '2' || '5' }}" in workflow + ) + assert "NOTEBOOK_EXECUTION_PROFILE: ${{ github.event_name == 'schedule'" in workflow + assert "NOTEBOOK_CACHE_CONTEXT=${NOTEBOOK_EXECUTION_PROFILE}:" in workflow + assert workflow.count("'docs/scripts/build_notebooks_cached.sh'") == 2 + assert "key: notebooks-${{ env.NOTEBOOK_EXECUTION_PROFILE }}-" in workflow + assert "notebooks-${{ env.NOTEBOOK_EXECUTION_PROFILE }}-\n" in workflow + assert "gh run list --workflow build-fern-docs.yml --status success" in workflow + + +def test_notebook_cache_can_be_disabled() -> None: + workflow = (WORKFLOWS_DIR / "build-notebooks.yml").read_text() + + assert "NOTEBOOK_CACHE_ENABLED: ${{ inputs.use_cache && '1' || '0' }}" in workflow + assert 'if [ "$NOTEBOOK_CACHE_ENABLED" != "1" ]; then' in workflow + assert "rm -rf .notebook-cache" in workflow + + +def test_fern_publish_excludes_cancelled_notebook_builds() -> None: + workflow = (WORKFLOWS_DIR / "build-fern-docs.yml").read_text() + + assert "(needs.build-notebooks.result == 'success' || needs.build-notebooks.result == 'failure')" in workflow + assert "if: needs.build-notebooks.result == 'failure'" in workflow + + +def test_fern_publish_persists_and_restores_notebook_snapshots() -> None: + workflow = (WORKFLOWS_DIR / "build-fern-docs.yml").read_text() + + assert "Publish source notebook snapshot" in workflow + assert 'source-fallback "$archive"' in workflow + assert "Restore prepared notebook snapshot" in workflow + assert 'gh release download "$release_tag"' in workflow + assert "Publish executed notebook snapshot" in workflow + assert 'executed "$archive"' in workflow + assert "git add fern/notebook-snapshot.json" in workflow + + +def test_devnotes_publish_does_not_reuse_notebook_artifacts() -> None: + workflow = (WORKFLOWS_DIR / "publish-fern-devnotes.yml").read_text() + + assert "Reuse notebooks from last successful docs build" not in workflow + assert "gh run download" not in workflow + assert "Require published notebook snapshot" in workflow + assert "Run the Build Fern docs workflow successfully once" in workflow + assert "Restore published notebook snapshot" in workflow + assert 'gh release download "$release_tag"' in workflow + assert "run: make check-fern-published-docs" in workflow + assert "run: make check-fern-docs\n" not in workflow diff --git a/packages/data-designer/tests/docs/test_fern_notebook_snapshot.py b/packages/data-designer/tests/docs/test_fern_notebook_snapshot.py new file mode 100644 index 000000000..bc53094ed --- /dev/null +++ b/packages/data-designer/tests/docs/test_fern_notebook_snapshot.py @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +SCRIPT_PATH = Path(__file__).resolve().parents[4] / "docs" / "scripts" / "fern_notebook_snapshot.sh" + + +def write_notebooks(root: Path) -> dict[str, str]: + notebooks = { + "example.json": '{"cells": [{"output": "rendered"}]}\n', + "manifest.ts": 'export const notebooks = ["example"];\n', + } + notebook_dir = root / "fern" / "components" / "notebooks" + notebook_dir.mkdir(parents=True) + for name, content in notebooks.items(): + (notebook_dir / name).write_text(content) + return notebooks + + +@pytest.mark.parametrize("mode", ["executed", "source-fallback"]) +def test_snapshot_round_trip(tmp_path: Path, mode: str) -> None: + root = tmp_path / "website" + expected = write_notebooks(root) + archive = tmp_path / f"notebooks-{mode}.tar.gz" + env = {**os.environ, "GITHUB_RUN_ID": "123", "GITHUB_RUN_ATTEMPT": "2"} + + subprocess.run( + [SCRIPT_PATH, "create", root, "v1.2.3", mode, archive], + check=True, + env=env, + ) + + metadata = json.loads((root / "fern" / "notebook-snapshot.json").read_text()) + digest = metadata.pop("sha256") + assert metadata == { + "schema_version": 1, + "release_tag": "v1.2.3", + "asset": archive.name, + "mode": mode, + "run_id": "123", + "run_attempt": "2", + } + assert len(digest) == 64 + + notebook_dir = root / "fern" / "components" / "notebooks" + (notebook_dir / "example.json").write_text("stale") + subprocess.run([SCRIPT_PATH, "restore", root, archive], check=True) + + assert {path.name: path.read_text() for path in notebook_dir.iterdir()} == expected + + +def test_restore_rejects_invalid_checksum_without_replacing_notebooks(tmp_path: Path) -> None: + root = tmp_path / "website" + write_notebooks(root) + archive = tmp_path / "notebooks.tar.gz" + subprocess.run([SCRIPT_PATH, "create", root, "v1.2.3", "executed", archive], check=True) + archive.write_bytes(archive.read_bytes() + b"corrupt") + + result = subprocess.run([SCRIPT_PATH, "restore", root, archive], check=False, capture_output=True, text=True) + + assert result.returncode != 0 + assert "checksum mismatch" in result.stderr + assert (root / "fern" / "components" / "notebooks" / "example.json").exists() diff --git a/packages/data-designer/tests/docs/test_fern_published_branch.py b/packages/data-designer/tests/docs/test_fern_published_branch.py index 8e7e425e1..a8a06ba43 100644 --- a/packages/data-designer/tests/docs/test_fern_published_branch.py +++ b/packages/data-designer/tests/docs/test_fern_published_branch.py @@ -112,6 +112,10 @@ def test_patch_devnotes_syncs_root_config_and_preserves_published_versions(tmp_p published_root / "fern" / "publish-metadata.json", '{"action": "release-snapshot", "release_tag": "v0.6.0"}\n', ) + write_text( + published_root / "fern" / "notebook-snapshot.json", + '{"release_tag": "v0.6.0", "asset": "notebooks.tar.gz"}\n', + ) write_text(published_root / "fern" / "assets" / "published-only-asset.png", "old asset") write_text( published_root / "fern" / "versions" / "latest.yml", @@ -178,6 +182,9 @@ def test_patch_devnotes_syncs_root_config_and_preserves_published_versions(tmp_p assert (published_root / "fern" / "publish-metadata.json").read_text() == ( '{"action": "release-snapshot", "release_tag": "v0.6.0"}\n' ) + assert (published_root / "fern" / "notebook-snapshot.json").read_text() == ( + '{"release_tag": "v0.6.0", "asset": "notebooks.tar.gz"}\n' + ) assert (published_root / "fern" / "versions" / "latest" / "pages" / "devnotes" / "posts" / "new-note.mdx").exists() published_nav = (published_root / "fern" / "versions" / "latest.yml").read_text() assert published_nav.count("section: Recipes") == 1 From 5fe3dc2e880c7a4325ad5a2cd9fa2a869ed6724a Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Thu, 30 Jul 2026 21:03:41 -0300 Subject: [PATCH 4/4] fix: harden Fern notebook snapshot validation --- .github/workflows/build-fern-docs.yml | 4 + .../tests/docs/test_build_notebooks_cached.py | 77 +++++++++++++++++++ .../tests/docs/test_docs_workflows.py | 1 + .../tests/docs/test_fern_notebook_snapshot.py | 45 +++++++++++ 4 files changed, 127 insertions(+) create mode 100644 packages/data-designer/tests/docs/test_build_notebooks_cached.py diff --git a/.github/workflows/build-fern-docs.yml b/.github/workflows/build-fern-docs.yml index c1d59344c..21c3a8f55 100644 --- a/.github/workflows/build-fern-docs.yml +++ b/.github/workflows/build-fern-docs.yml @@ -255,6 +255,10 @@ jobs: SOURCE_REPOSITORY: ${{ github.repository }} working-directory: website run: | + if [ ! -f fern/notebook-snapshot.json ]; then + echo "::error::No canonical notebook snapshot is published. Run the Build Fern docs workflow successfully once." + exit 1 + fi release_tag=$(jq -er '.release_tag' fern/notebook-snapshot.json) asset=$(jq -er '.asset' fern/notebook-snapshot.json) snapshot_dir="$RUNNER_TEMP/fern-notebook-snapshot" diff --git a/packages/data-designer/tests/docs/test_build_notebooks_cached.py b/packages/data-designer/tests/docs/test_build_notebooks_cached.py new file mode 100644 index 000000000..9b9352559 --- /dev/null +++ b/packages/data-designer/tests/docs/test_build_notebooks_cached.py @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +SCRIPT_PATH = Path(__file__).resolve().parents[4] / "docs" / "scripts" / "build_notebooks_cached.sh" + + +@pytest.mark.parametrize( + ("setting", "value", "message"), + [ + ("NOTEBOOK_EXECUTION_ATTEMPTS", "invalid", "must be a positive integer"), + ("NOTEBOOK_RETRY_DELAY_SECONDS", "-1", "must be a non-negative integer"), + ], +) +def test_rejects_invalid_retry_settings(tmp_path: Path, setting: str, value: str, message: str) -> None: + env = {**os.environ, setting: value} + + result = subprocess.run([SCRIPT_PATH, tmp_path], check=False, capture_output=True, text=True, env=env) + + assert result.returncode != 0 + assert message in result.stdout + + +def test_retries_failed_notebook_execution(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + script = repo_root / "docs" / "scripts" / SCRIPT_PATH.name + script.parent.mkdir(parents=True) + shutil.copy2(SCRIPT_PATH, script) + + source_dir = repo_root / "docs" / "notebook_source" + source_dir.mkdir() + (source_dir / "_README.md").write_text("README\n") + (source_dir / "_pyproject.toml").write_text('[project]\nname = "notebooks"\n') + (source_dir / "example.py").write_text("print('example')\n") + + attempts_file = tmp_path / "attempts" + jupytext = tmp_path / "jupytext" + jupytext.write_text( + """#!/usr/bin/env bash +set -euo pipefail +attempt=0 +if [ -f "$ATTEMPTS_FILE" ]; then + attempt=$(<"$ATTEMPTS_FILE") +fi +attempt=$((attempt + 1)) +printf '%s' "$attempt" > "$ATTEMPTS_FILE" +if [ "$attempt" -lt 2 ]; then + exit 1 +fi +src="${!#}" +printf '%s\n' '{"cells": []}' > "${src%.py}.ipynb" +""" + ) + jupytext.chmod(0o755) + env = { + **os.environ, + "ATTEMPTS_FILE": str(attempts_file), + "DOCS_JUPYTEXT": str(jupytext), + "NOTEBOOK_CACHE_CONTEXT": "test-context", + "NOTEBOOK_EXECUTION_ATTEMPTS": "2", + "NOTEBOOK_RETRY_DELAY_SECONDS": "0", + } + + result = subprocess.run([script], check=True, capture_output=True, text=True, env=env) + + assert attempts_file.read_text() == "2" + assert "Attempt 1 failed; retrying" in result.stdout + assert (repo_root / "docs" / "notebooks" / "example.ipynb").exists() + assert (repo_root / ".notebook-cache" / "example.sha256").exists() diff --git a/packages/data-designer/tests/docs/test_docs_workflows.py b/packages/data-designer/tests/docs/test_docs_workflows.py index 114b50b2e..77445c703 100644 --- a/packages/data-designer/tests/docs/test_docs_workflows.py +++ b/packages/data-designer/tests/docs/test_docs_workflows.py @@ -43,6 +43,7 @@ def test_fern_publish_persists_and_restores_notebook_snapshots() -> None: assert "Publish source notebook snapshot" in workflow assert 'source-fallback "$archive"' in workflow assert "Restore prepared notebook snapshot" in workflow + assert "Run the Build Fern docs workflow successfully once" in workflow assert 'gh release download "$release_tag"' in workflow assert "Publish executed notebook snapshot" in workflow assert 'executed "$archive"' in workflow diff --git a/packages/data-designer/tests/docs/test_fern_notebook_snapshot.py b/packages/data-designer/tests/docs/test_fern_notebook_snapshot.py index bc53094ed..f201de630 100644 --- a/packages/data-designer/tests/docs/test_fern_notebook_snapshot.py +++ b/packages/data-designer/tests/docs/test_fern_notebook_snapshot.py @@ -3,9 +3,11 @@ from __future__ import annotations +import hashlib import json import os import subprocess +import tarfile from pathlib import Path import pytest @@ -69,3 +71,46 @@ def test_restore_rejects_invalid_checksum_without_replacing_notebooks(tmp_path: assert result.returncode != 0 assert "checksum mismatch" in result.stderr assert (root / "fern" / "components" / "notebooks" / "example.json").exists() + + +def test_restore_rejects_asset_name_mismatch(tmp_path: Path) -> None: + root = tmp_path / "website" + write_notebooks(root) + archive = tmp_path / "notebooks.tar.gz" + subprocess.run([SCRIPT_PATH, "create", root, "v1.2.3", "executed", archive], check=True) + renamed_archive = tmp_path / "different-name.tar.gz" + renamed_archive.write_bytes(archive.read_bytes()) + + result = subprocess.run( + [SCRIPT_PATH, "restore", root, renamed_archive], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "asset mismatch" in result.stderr + assert (root / "fern" / "components" / "notebooks" / "example.json").exists() + + +def test_restore_rejects_unexpected_archive_paths_without_replacing_notebooks(tmp_path: Path) -> None: + root = tmp_path / "website" + write_notebooks(root) + archive = tmp_path / "notebooks.tar.gz" + subprocess.run([SCRIPT_PATH, "create", root, "v1.2.3", "executed", archive], check=True) + + unexpected_file = tmp_path / "unexpected.txt" + unexpected_file.write_text("unexpected") + with tarfile.open(archive, "w:gz") as snapshot: + snapshot.add(unexpected_file, arcname="unexpected.txt") + + metadata_path = root / "fern" / "notebook-snapshot.json" + metadata = json.loads(metadata_path.read_text()) + metadata["sha256"] = hashlib.sha256(archive.read_bytes()).hexdigest() + metadata_path.write_text(json.dumps(metadata)) + + result = subprocess.run([SCRIPT_PATH, "restore", root, archive], check=False, capture_output=True, text=True) + + assert result.returncode != 0 + assert "unexpected paths" in result.stderr + assert (root / "fern" / "components" / "notebooks" / "example.json").exists()