From 2d3c4be80b3d5a92553d5502e03e3c3f2797e721 Mon Sep 17 00:00:00 2001 From: Maarten Grootendorst Date: Fri, 6 Nov 2026 09:09:55 +0000 Subject: [PATCH] Add preview build overrides, sync, and publish workflows --- .github/workflows/lint.yml | 20 ++-- .github/workflows/pr-preview.yml | 170 +++++++++++++++++++++++++++ .github/workflows/publish.yml | 89 ++++++++++++++ .github/workflows/sync.yml | 42 +++++++ overrides/main.html | 2 +- overrides/partials/page-context.html | 5 +- scripts/build_docs.py | 75 +++++++++++- tests/test_analytics.py | 63 ++++++++++ 8 files changed, 448 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/pr-preview.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/sync.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 08886e8..372d215 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,21 +21,21 @@ jobs: with: persist-credentials: false - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 - with: - python-version: "3.13" - - # The generators use Jinja for raw Markdown and PyYAML for structured frontmatter. - - name: Install linter dependencies - run: pip install pyyaml==6.0.3 jinja2==3.1.6 + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Script tests - run: python -m unittest discover -s tests -p "test_*.py" + run: uv run --frozen python -m unittest discover -s tests -p "test_*.py" - name: Lint documentation - run: python scripts/lint_docs.py --baseline + run: uv run --frozen python scripts/lint_docs.py --baseline + + - name: Build documentation + env: + GA_MEASUREMENT_ID: "" + run: uv run --frozen python scripts/build_docs.py # Advisory: shows the full Phase 0 backlog without affecting the job result. - name: Backlog (informational) if: always() - run: python scripts/lint_docs.py || true + run: uv run --frozen python scripts/lint_docs.py || true diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml new file mode 100644 index 0000000..05be45c --- /dev/null +++ b/.github/workflows/pr-preview.yml @@ -0,0 +1,170 @@ +name: PR Preview + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + +permissions: + contents: read + +jobs: + preview: + if: >- + github.event.repository.private == true && + vars.ENABLE_PR_PREVIEWS == 'true' && + vars.PAGES_PREVIEW_ORIGIN != '' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + concurrency: + group: "pages" + cancel-in-progress: false + permissions: + contents: write # Update gh-pages-previews branch. + pages: write # Deploy GitHub Pages bundle. + id-token: write # Mint OIDC token for actions/deploy-pages. + pull-requests: write # Post sticky preview link on PR. + environment: + name: github-pages + url: ${{ steps.comment.outputs.preview_url }} + steps: + - if: github.event.action != 'closed' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - if: github.event.action != 'closed' + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + + - name: Build preview + if: github.event.action != 'closed' + env: + SITE_URL: "${{ vars.PAGES_PREVIEW_ORIGIN }}/pr-${{ github.event.pull_request.number }}" + COLAB_REPO: ${{ github.repository }} + COLAB_BRANCH: ${{ github.event.pull_request.head.ref }} + run: uv run --frozen python scripts/build_docs.py + + - name: Update preview branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_ACTION: ${{ github.event.action }} + run: | + set -euo pipefail + [[ "${PR_NUMBER}" =~ ^[1-9][0-9]*$ ]] + [[ "${GH_REPO}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] + + OPEN_PRS="$(gh api --paginate "repos/${GH_REPO}/pulls?state=open&per_page=100" --jq '.[].number')" + AUTH_B64="$(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 -w0)" + export GIT_TERMINAL_PROMPT=0 + export GIT_CONFIG_COUNT=1 + export GIT_CONFIG_KEY_0="http.https://github.com/.extraheader" + export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${AUTH_B64}" + REMOTE="https://github.com/${GH_REPO}.git" + + rm -rf _pages + if ! git clone --depth=1 --single-branch --branch gh-pages-previews "${REMOTE}" _pages 2>/dev/null; then + rm -rf _pages + git init _pages + git -C _pages remote add origin "${REMOTE}" + git -C _pages checkout --orphan gh-pages-previews + fi + + for dir in _pages/pr-*; do + [[ -d "${dir}" ]] || continue + num="${dir#_pages/pr-}" + if ! [[ "${num}" =~ ^[1-9][0-9]*$ ]] || [[ "${num}" == "${PR_NUMBER}" ]] || ! grep -Fxq -- "${num}" <<<"${OPEN_PRS}"; then + rm -rf -- "${dir}" + fi + done + if [[ "${EVENT_ACTION}" != "closed" ]]; then + mv site "_pages/pr-${PR_NUMBER}" + fi + + { + printf '' + printf 'PR Previews

PR Previews (%s)

\n' + } > _pages/index.html + : > _pages/.nojekyll + printf 'User-agent: *\nDisallow: /\n' > _pages/robots.txt + + git -C _pages config user.name "github-actions[bot]" + git -C _pages config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git -C _pages add -A + if [[ -n "$(git -C _pages status --porcelain)" ]]; then + git -C _pages commit -m "PR #${PR_NUMBER} (${EVENT_ACTION})" + git -C _pages push origin HEAD:refs/heads/gh-pages-previews + fi + rm -rf _pages/.git + + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 + with: + path: _pages + + - id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5 + + - name: Update PR comment + id: comment + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EVENT_ACTION: ${{ github.event.action }} + PAGE_URL: ${{ steps.deployment.outputs.page_url }} + run: | + python3 - <<'PY' + import json, os, subprocess + + pr_num = int(os.environ["PR_NUMBER"]) + action = os.environ["EVENT_ACTION"].strip() + gh_repo = os.environ["GH_REPO"].strip() + base_url = os.environ["PAGE_URL"].strip().rstrip("/") + preview_url = f"{base_url}/pr-{pr_num}/" + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: + fh.write(f"preview_url={preview_url if action != 'closed' else base_url + '/'}\n") + + marker = "" + if action == "closed": + body = f"{marker}\nPreview `/pr-{pr_num}/` removed (PR closed)." + else: + sha = os.environ["HEAD_SHA"].strip()[:7] + pages_raw = subprocess.check_output( + ["gh", "api", "--paginate", "--slurp", f"repos/{gh_repo}/pulls/{pr_num}/files?per_page=100"], + text=True, + ) + routes = [] + for f in (item for p in (json.loads(pages_raw) if pages_raw.strip() else []) for item in p): + name = str(f.get("filename", "")) + if f.get("status") == "removed" or not name.startswith("docs/") or not name.endswith(".md") or name.startswith("docs/contributing/"): + continue + rel = name[5:-3] + route = "" if rel == "index" else (f"{rel[:-6]}/" if rel.endswith("/index") else f"{rel}/") + routes.append(f"- `{name}` → {preview_url}{route}") + lines = [marker, f"**PR Preview:** {preview_url} (`{sha}`)"] + if routes: + lines.extend(["", "**Modified pages:**", *routes[:20]]) + if len(routes) > 20: + lines.append(f"- *…and {len(routes) - 20} more*") + body = "\n".join(lines) + + comments_raw = subprocess.check_output( + ["gh", "api", "--paginate", "--slurp", f"repos/{gh_repo}/issues/{pr_num}/comments?per_page=100"], + text=True, + ) + comments = [c for p in (json.loads(comments_raw) if comments_raw.strip() else []) for c in p] + cid = next((c["id"] for c in comments if isinstance(c, dict) and marker in str(c.get("body", ""))), None) + endpoint = f"repos/{gh_repo}/issues/comments/{cid}" if cid else f"repos/{gh_repo}/issues/{pr_num}/comments" + subprocess.run(["gh", "api", "--method", "PATCH" if cid else "POST", endpoint, "--input", "-"], input=json.dumps({"body": body}), text=True, check=True) + PY diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..569a603 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,89 @@ +name: Publish + +on: + workflow_dispatch: + inputs: + source_branch: + description: "Source branch name" + required: true + type: string + commit_message: + description: "Commit message" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: publish + cancel-in-progress: false + +jobs: + publish: + if: github.event.repository.private == true + runs-on: ubuntu-latest + environment: publish + steps: + - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + id: app-token + with: + client-id: ${{ vars.PUBLISHER_APP_CLIENT_ID }} + private-key: ${{ secrets.PUBLISHER_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: "docs,${{ github.event.repository.name }}" + permission-contents: write + permission-pull-requests: write + + - name: Verify approval and push squashed commit + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + SOURCE_REPO: ${{ github.repository }} + REPO_OWNER: ${{ github.repository_owner }} + SOURCE_BRANCH: ${{ inputs.source_branch }} + COMMIT_MESSAGE: ${{ inputs.commit_message }} + ACTOR_LOGIN: ${{ github.actor }} + ACTOR_ID: ${{ github.actor_id }} + run: | + set -euo pipefail + [[ "${SOURCE_REPO}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ && "${SOURCE_REPO}" != "google-gemma/docs" ]] + [[ "${SOURCE_BRANCH}" != "main" && "${SOURCE_BRANCH}" =~ ^[A-Za-z0-9_./-]+$ ]] + git check-ref-format --branch "${SOURCE_BRANCH}" >/dev/null + [[ -n "${COMMIT_MESSAGE// /}" ]] + + git init repo && cd repo + AUTH_B64="$(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 -w0)" + git config "http.https://github.com/.extraheader" "AUTHORIZATION: basic ${AUTH_B64}" + git remote add upstream https://github.com/google-gemma/docs.git + git remote add origin "https://github.com/${SOURCE_REPO}.git" + git fetch --no-tags upstream main + git fetch --no-tags origin "refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/target" + UPSTREAM_SHA="$(git rev-parse refs/remotes/upstream/main)" + TARGET_SHA="$(git rev-parse refs/remotes/origin/target)" + git merge-base --is-ancestor "${UPSTREAM_SHA}" "${TARGET_SHA}" + [[ "$(git rev-parse "${TARGET_SHA}^{tree}")" != "$(git rev-parse "${UPSTREAM_SHA}^{tree}")" ]] + + PR_JSON="$(gh api -X GET "repos/${SOURCE_REPO}/pulls" -f state=open -f base=main -f "head=${REPO_OWNER}:${SOURCE_BRANCH}")" + PR_NUMBER="$(printf '%s' "${PR_JSON}" | jq -r '.[0].number // empty')" + PR_AUTHOR="$(printf '%s' "${PR_JSON}" | jq -r '.[0].user.login // empty')" + PR_HEAD="$(printf '%s' "${PR_JSON}" | jq -r '.[0].head.sha // empty')" + [[ -n "${PR_NUMBER}" && "${PR_HEAD}" == "${TARGET_SHA}" ]] + + REVIEWS_JSON="$(gh api --paginate --slurp "repos/${SOURCE_REPO}/pulls/${PR_NUMBER}/reviews?per_page=100")" + APPROVERS="$(printf '%s' "${REVIEWS_JSON}" | jq -r --arg a "${PR_AUTHOR}" \ + 'flatten | map(select(.user.login != null and .user.login != $a and (.state == "APPROVED" or .state == "CHANGES_REQUESTED" or .state == "DISMISSED"))) | group_by(.user.login) | map(sort_by(.id) | last) | .[] | select(.state == "APPROVED") | .user.login')" + VALID_LGTM=0 + for reviewer in ${APPROVERS}; do + perm="$(gh api "repos/${SOURCE_REPO}/collaborators/${reviewer}/permission" --jq '.permission')" + case "${perm}" in admin|maintain|write) VALID_LGTM=1; break ;; esac + done + [[ "${VALID_LGTM}" -ge 1 ]] + + export GIT_AUTHOR_NAME="${ACTOR_LOGIN}" + export GIT_AUTHOR_EMAIL="${ACTOR_ID}+${ACTOR_LOGIN}@users.noreply.github.com" + export GIT_COMMITTER_NAME="${GIT_AUTHOR_NAME}" + export GIT_COMMITTER_EMAIL="${GIT_AUTHOR_EMAIL}" + SQUASH_SHA="$(git commit-tree "${TARGET_SHA}^{tree}" -p "${UPSTREAM_SHA}" -m "${COMMIT_MESSAGE}")" + git push upstream "${SQUASH_SHA}:refs/heads/main" --ff-only + git push origin "${SQUASH_SHA}:refs/heads/main" --ff-only + gh api -X PATCH "repos/${SOURCE_REPO}/pulls/${PR_NUMBER}" -f state=closed diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml new file mode 100644 index 0000000..f955e2b --- /dev/null +++ b/.github/workflows/sync.yml @@ -0,0 +1,42 @@ +name: Sync + +on: + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: publish + cancel-in-progress: false + +jobs: + sync: + if: github.event.repository.private == true + runs-on: ubuntu-latest + environment: publish + steps: + - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + id: app-token + with: + client-id: ${{ vars.PUBLISHER_APP_CLIENT_ID }} + private-key: ${{ secrets.PUBLISHER_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: "docs,${{ github.event.repository.name }}" + permission-contents: write + permission-workflows: write + + - name: Fast-forward main + env: + GH_APP_TOKEN: ${{ steps.app-token.outputs.token }} + TARGET_REPO: ${{ github.repository }} + run: | + set -euo pipefail + [[ "${TARGET_REPO}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ && "${TARGET_REPO}" != "google-gemma/docs" ]] + git init repo && cd repo + AUTH_B64="$(printf 'x-access-token:%s' "${GH_APP_TOKEN}" | base64 -w0)" + git config "http.https://github.com/.extraheader" "AUTHORIZATION: basic ${AUTH_B64}" + git fetch --no-tags https://github.com/google-gemma/docs.git main:refs/remotes/upstream/main + git push "https://github.com/${TARGET_REPO}.git" refs/remotes/upstream/main:refs/heads/main --ff-only diff --git a/overrides/main.html b/overrides/main.html index 4799cdb..bb40fee 100644 --- a/overrides/main.html +++ b/overrides/main.html @@ -65,7 +65,7 @@ {% endif %} {% endif %} - {% if page and page.meta and page.meta.search and page.meta.search.exclude %} + {% if (page and page.meta and page.meta.search and page.meta.search.exclude) or (config.site_url and (config.site_url | trim("/")) != "https://gemma.ai") %} {% else %} diff --git a/overrides/partials/page-context.html b/overrides/partials/page-context.html index dc56660..f249171 100644 --- a/overrides/partials/page-context.html +++ b/overrides/partials/page-context.html @@ -21,7 +21,7 @@ {%- set requires = page.meta.requires if page.meta and page.meta.requires else "" -%} {%- set frameworks = page.meta.frameworks if page.meta and page.meta.frameworks else [] -%} {%- set colab = page.meta.colab if page.meta and page.meta.colab else "" -%} -{%- set markdown_url = ("/" ~ (page.url | trim("/")) ~ ".md.txt") if page and page.url else "" -%} +{%- set markdown_url = (((page.url | trim("/")) ~ ".md.txt") | url) if page and page.url else "" -%} {%- set ns = namespace(families=[]) -%} {%- for id in models -%} {%- set family = id | split("/") | first -%} @@ -37,7 +37,8 @@ {%- for family in ns.families %} {%- set url = gemma_model_urls[family] | default("") %} {%- if url %} - {{ gemma_model_names[family] | default(family) }} + {%- set resolved_url = (((url | trim("/")) ~ "/") | url) if url[:1] == "/" else url %} + {{ gemma_model_names[family] | default(family) }} {%- else %} {{ gemma_model_names[family] | default(family) }} {%- endif %} diff --git a/scripts/build_docs.py b/scripts/build_docs.py index eabc2ae..84a9f73 100644 --- a/scripts/build_docs.py +++ b/scripts/build_docs.py @@ -25,12 +25,17 @@ from contextlib import contextmanager from pathlib import Path from typing import Iterator +from urllib.parse import quote import agent_docs ANALYTICS_PROPERTY_RE = re.compile( r'(\[project\.extra\.analytics\][\s\S]*?\bproperty\s*=\s*)"[^"]*"' ) +SITE_URL_RE = re.compile(r'^(\s*site_url\s*=\s*)"[^"]*"', re.MULTILINE) +COLAB_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +COLAB_BRANCH_RE = re.compile(r"^[A-Za-z0-9_./-]+$") +PUBLIC_COLAB_PREFIX = "https://colab.research.google.com/github/google-gemma/docs/blob/main/" def inject_analytics_property(config_text: str, measurement_id: str) -> str: @@ -40,17 +45,64 @@ def inject_analytics_property(config_text: str, measurement_id: str) -> str: return ANALYTICS_PROPERTY_RE.sub(rf'\g<1>"{measurement_id}"', config_text, count=1) +def inject_site_url(config_text: str, site_url: str) -> str: + """Return config_text with project.site_url set to site_url.""" + if not SITE_URL_RE.search(config_text): + raise ValueError("Could not locate site_url in zensical.toml") + normalized = re.sub(r"(? int: + """Rewrite canonical in-repo Colab links in built HTML files to target repo and branch.""" + clean_repo = repo.strip() + clean_branch = branch.strip() + if not COLAB_REPO_RE.fullmatch(clean_repo): + raise ValueError(f"Invalid COLAB_REPO: {repo!r}") + if ( + not COLAB_BRANCH_RE.fullmatch(clean_branch) + or clean_branch.startswith("/") + or clean_branch.endswith("/") + or ".." in clean_branch + ): + raise ValueError(f"Invalid COLAB_BRANCH: {branch!r}") + + # URL-encode "/" as "%2F" so compound branch names stay a single path segment in Colab. + encoded_branch = quote(clean_branch, safe="._-") + target_prefix = f"https://colab.research.google.com/github/{clean_repo}/blob/{encoded_branch}/" + if target_prefix == PUBLIC_COLAB_PREFIX: + return 0 + + rewritten_files = 0 + for html_path in site_dir.rglob("*.html"): + text = html_path.read_text(encoding="utf-8") + if PUBLIC_COLAB_PREFIX in text: + html_path.write_text( + text.replace(PUBLIC_COLAB_PREFIX, target_prefix), + encoding="utf-8", + ) + rewritten_files += 1 + return rewritten_files + + @contextmanager -def build_config(measurement_id: str | None) -> Iterator[Path | None]: - """Yield a temporary Zensical config path with GA4 measurement ID injected if set.""" - if measurement_id is None: +def build_config( + measurement_id: str | None, + site_url: str | None = None, +) -> Iterator[Path | None]: + """Yield a temporary Zensical config path with optional GA4 and site_url overrides.""" + if measurement_id is None and site_url is None: yield None return source_config = agent_docs.REPO_ROOT / "zensical.toml" temp_config = agent_docs.REPO_ROOT / ".zensical.build.toml" content = source_config.read_text(encoding="utf-8") - temp_config.write_text(inject_analytics_property(content, measurement_id), encoding="utf-8") + if measurement_id is not None: + content = inject_analytics_property(content, measurement_id) + if site_url is not None: + content = inject_site_url(content, site_url) + temp_config.write_text(content, encoding="utf-8") try: yield temp_config finally: @@ -87,19 +139,32 @@ def main() -> int: agent_docs.write_generated() raw_measurement_id = os.environ.get("GA_MEASUREMENT_ID") measurement_id = raw_measurement_id.strip() if raw_measurement_id is not None else None + raw_site_url = os.environ.get("SITE_URL") + site_url = raw_site_url.strip() if raw_site_url is not None and raw_site_url.strip() else None + is_non_prod_site = site_url is not None and site_url.rstrip("/") != "https://gemma.ai" + if is_non_prod_site: + measurement_id = "" + raw_colab_repo = os.environ.get("COLAB_REPO") + colab_repo = raw_colab_repo.strip() if raw_colab_repo is not None and raw_colab_repo.strip() else None + raw_colab_branch = os.environ.get("COLAB_BRANCH") + colab_branch = ( + raw_colab_branch.strip() if raw_colab_branch is not None and raw_colab_branch.strip() else None + ) args = sys.argv[1:] site_dir = agent_docs.REPO_ROOT / "site" for prefix in agent_docs.HIDDEN_PREFIXES: stale = site_dir / prefix.strip("/") if stale.exists(): shutil.rmtree(stale) - with hidden_docs(), build_config(measurement_id) as temp_config: + with hidden_docs(), build_config(measurement_id, site_url) as temp_config: if temp_config and "-f" not in args and "--config-file" not in args: args = ["-f", str(temp_config), *args] command = [sys.executable, "-m", "zensical", "build", *args] result = subprocess.run(command, cwd=agent_docs.REPO_ROOT, check=False) if result.returncode: return result.returncode + if is_non_prod_site and colab_repo and colab_branch: + rewrite_colab_links(site_dir, colab_repo, colab_branch) agent_docs.publish(site_dir) return 0 diff --git a/tests/test_analytics.py b/tests/test_analytics.py index 5ad400d..5208def 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -18,6 +18,7 @@ import os import subprocess import sys +import tempfile import unittest from pathlib import Path @@ -36,6 +37,46 @@ def test_inject_analytics_property_updates_config(self): cleared = build_docs.inject_analytics_property(source, "") self.assertIn('property = ""', cleared) + def test_inject_site_url_updates_config(self): + source = (build_docs.agent_docs.REPO_ROOT / "zensical.toml").read_text(encoding="utf-8") + self.assertIn('site_url = "https://gemma.ai"', source) + injected = build_docs.inject_site_url(source, "https://example.invalid//pr-42") + self.assertIn('site_url = "https://example.invalid/pr-42"', injected) + + def test_rewrite_colab_links_handles_simple_and_compound_branches(self): + with tempfile.TemporaryDirectory() as tmp: + site_dir = Path(tmp) + page = site_dir / "page" / "index.html" + page.parent.mkdir(parents=True) + sample_html = ( + 'Colab\n' + 'External\n' + ) + page.write_text(sample_html, encoding="utf-8") + md_txt = site_dir / "page.md.txt" + md_txt.write_text(sample_html, encoding="utf-8") + + count = build_docs.rewrite_colab_links(site_dir, "org/repo", "sub/branch") + self.assertEqual(count, 1) + rewritten = page.read_text(encoding="utf-8") + self.assertIn( + "https://colab.research.google.com/github/org/repo/blob/" + "sub%2Fbranch/docs/notebooks/notebook.ipynb", + rewritten, + ) + self.assertIn( + "https://colab.research.google.com/github/google-gemma/cookbook/blob/main/demo.ipynb", + rewritten, + ) + self.assertEqual(md_txt.read_text(encoding="utf-8"), sample_html) + + with self.assertRaises(ValueError): + build_docs.rewrite_colab_links(site_dir, "bad repo", "main") + with self.assertRaises(ValueError): + build_docs.rewrite_colab_links(site_dir, "org/repo", "../bad") + @unittest.skipUnless(HAS_ZENSICAL, "zensical is required for full build tests") def test_build_without_measurement_id_omits_tracking_script(self): env = {**os.environ, "GA_MEASUREMENT_ID": ""} @@ -73,6 +114,28 @@ def test_build_with_measurement_id_includes_ga4_and_consent(self): # Ensure temporary config file is cleaned up self.assertFalse((build_docs.agent_docs.REPO_ROOT / ".zensical.build.toml").exists()) + @unittest.skipUnless(HAS_ZENSICAL, "zensical is required for full build tests") + def test_non_prod_site_url_disables_analytics_and_sets_noindex(self): + env = { + **os.environ, + "GA_MEASUREMENT_ID": "G-TEST123456", + "SITE_URL": "https://example.invalid/pr-1", + } + result = subprocess.run( + [sys.executable, "scripts/build_docs.py"], + cwd=build_docs.agent_docs.REPO_ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr) + index_html = (build_docs.agent_docs.REPO_ROOT / "site" / "index.html").read_text(encoding="utf-8") + self.assertNotIn("googletagmanager.com/gtag/js", index_html) + self.assertNotIn("G-TEST123456", index_html) + self.assertNotIn("G-CWMC2KTTGQ", index_html) + self.assertIn('', index_html) + if __name__ == "__main__": unittest.main()