From 0010a27e56f2b5f38cfac53e4c722f81dece0dfe Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Tue, 14 Jul 2026 09:55:45 -0700 Subject: [PATCH 1/5] Use targeted renders for docs PR previews --- .../scripts/select_docs_preview_targets.py | 118 +++++++++++++++++ .../test_select_docs_preview_targets.py | 65 ++++++++++ .../request-full-docs-validation.yaml | 57 ++++++++ .github/workflows/validate-docs-site.yaml | 122 ++++++++++++++---- 4 files changed, 337 insertions(+), 25 deletions(-) create mode 100644 .github/scripts/select_docs_preview_targets.py create mode 100644 .github/scripts/test_select_docs_preview_targets.py create mode 100644 .github/workflows/request-full-docs-validation.yaml diff --git a/.github/scripts/select_docs_preview_targets.py b/.github/scripts/select_docs_preview_targets.py new file mode 100644 index 0000000000..8632116892 --- /dev/null +++ b/.github/scripts/select_docs_preview_targets.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +"""Select pages that are safe to render incrementally for a PR preview.""" + +from __future__ import annotations + +import argparse +import subprocess +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + + +PAGE_SUFFIXES = {".qmd", ".md", ".ipynb"} +ASSET_SUFFIXES = {".avif", ".gif", ".jpeg", ".jpg", ".pdf", ".png", ".svg", ".webp"} +UNSAFE_TOP_LEVEL = { + "_extensions", + "_freeze", + "_source", + "environments", + "llm", + "scripts", +} + + +@dataclass(frozen=True) +class Selection: + targets: tuple[str, ...] = () + assets: tuple[str, ...] = () + fallback_reason: str | None = None + + @property + def is_targeted(self) -> bool: + return self.fallback_reason is None + + +def select(changes: list[tuple[str, tuple[str, ...]]]) -> Selection: + targets: set[str] = set() + assets: set[str] = set() + + for status, paths in changes: + if status not in {"A", "M"} or len(paths) != 1: + return Selection(fallback_reason=f"{status} change requires a full render") + + path = PurePosixPath(paths[0]) + if not path.parts or path.parts[0] != "site" or len(path.parts) < 2: + return Selection(fallback_reason=f"{path} is outside targetable site content") + + relative = PurePosixPath(*path.parts[1:]) + if relative.parts[0] in UNSAFE_TOP_LEVEL: + return Selection(fallback_reason=f"{path} can affect generated or global content") + if any(part.startswith("_") for part in relative.parts): + return Selection(fallback_reason=f"{path} is Quarto metadata or shared content") + + suffix = relative.suffix.lower() + if suffix in PAGE_SUFFIXES: + targets.add(relative.as_posix()) + elif suffix in ASSET_SUFFIXES: + assets.add(relative.as_posix()) + else: + return Selection(fallback_reason=f"{path} is not a targetable page or asset") + + if not targets: + return Selection(fallback_reason="no changed renderable pages were found") + + return Selection(tuple(sorted(targets)), tuple(sorted(assets))) + + +def parse_name_status(output: str) -> list[tuple[str, tuple[str, ...]]]: + changes: list[tuple[str, tuple[str, ...]]] = [] + for line in output.splitlines(): + fields = line.split("\t") + if len(fields) < 2: + raise ValueError(f"Unexpected git diff line: {line!r}") + changes.append((fields[0][0], tuple(fields[1:]))) + return changes + + +def git_changes(base: str, head: str) -> list[tuple[str, tuple[str, ...]]]: + result = subprocess.run( + ["git", "diff", "--name-status", "--find-renames", f"{base}...{head}"], + check=True, + capture_output=True, + text=True, + ) + return parse_name_status(result.stdout) + + +def write_lines(path: Path, values: tuple[str, ...]) -> None: + path.write_text("".join(f"{value}\n" for value in values)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base", required=True) + parser.add_argument("--head", default="HEAD") + parser.add_argument("--targets", type=Path, required=True) + parser.add_argument("--assets", type=Path, required=True) + args = parser.parse_args() + + selection = select(git_changes(args.base, args.head)) + if not selection.is_targeted: + print(f"Full render required: {selection.fallback_reason}") + return 3 + + write_lines(args.targets, selection.targets) + write_lines(args.assets, selection.assets) + print("Targeted render pages:") + print("\n".join(selection.targets)) + if selection.assets: + print("Targeted preview assets:") + print("\n".join(selection.assets)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/test_select_docs_preview_targets.py b/.github/scripts/test_select_docs_preview_targets.py new file mode 100644 index 0000000000..e43210e905 --- /dev/null +++ b/.github/scripts/test_select_docs_preview_targets.py @@ -0,0 +1,65 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +import unittest + +from select_docs_preview_targets import parse_name_status, select + + +class SelectDocsPreviewTargetsTest(unittest.TestCase): + def test_selects_changed_pages_and_assets(self): + result = select( + [ + ("M", ("site/guide/example.qmd",)), + ("A", ("site/guide/images/example.png",)), + ] + ) + + self.assertTrue(result.is_targeted) + self.assertEqual(result.targets, ("guide/example.qmd",)) + self.assertEqual(result.assets, ("guide/images/example.png",)) + + def test_global_quarto_change_requires_full_render(self): + result = select([("M", ("site/_quarto.yml",))]) + + self.assertFalse(result.is_targeted) + + def test_shared_metadata_requires_full_render(self): + result = select([("M", ("site/releases/_metadata.yml",))]) + + self.assertFalse(result.is_targeted) + + def test_deleted_page_requires_full_render(self): + result = select([("D", ("site/guide/old.qmd",))]) + + self.assertFalse(result.is_targeted) + + def test_non_site_change_requires_full_render(self): + result = select([("M", (".github/workflows/example.yaml",))]) + + self.assertFalse(result.is_targeted) + + def test_asset_only_change_requires_full_render(self): + result = select([("M", ("site/guide/images/example.png",))]) + + self.assertFalse(result.is_targeted) + + def test_generated_corpus_change_requires_full_render(self): + result = select([("M", ("site/llm/AGENTS.md",))]) + + self.assertFalse(result.is_targeted) + + def test_parses_renames_for_safe_fallback(self): + changes = parse_name_status( + "R100\tsite/guide/old.qmd\tsite/guide/new.qmd\n" + ) + + self.assertEqual( + changes, + [("R", ("site/guide/old.qmd", "site/guide/new.qmd"))], + ) + self.assertFalse(select(changes).is_targeted) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/request-full-docs-validation.yaml b/.github/workflows/request-full-docs-validation.yaml new file mode 100644 index 0000000000..fdc186c397 --- /dev/null +++ b/.github/workflows/request-full-docs-validation.yaml @@ -0,0 +1,57 @@ +name: Request full docs validation + +on: + pull_request: + types: [synchronize] + pull_request_review: + types: [submitted] + +permissions: + actions: write + contents: read + pull-requests: read + +jobs: + request: + if: >- + github.event_name != 'pull_request_review' || + github.event.review.state == 'approved' + runs-on: ubuntu-latest + steps: + - name: Dispatch validation for approved revision + uses: actions/github-script@v7 + with: + script: | + const pullRequest = context.payload.pull_request; + const result = await github.graphql(` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewDecision + } + } + } + `, { + owner: context.repo.owner, + repo: context.repo.repo, + number: pullRequest.number, + }); + + const decision = result.repository.pullRequest.reviewDecision; + if (decision !== 'APPROVED') { + console.log(`PR #${pullRequest.number} is ${decision || 'not approved'}; no full validation requested.`); + return; + } + + if (pullRequest.head.repo.full_name !== context.payload.repository.full_name) { + core.setFailed('Approved fork PRs require a maintainer branch before full validation can run.'); + return; + } + + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'full-docs-validation.yaml', + ref: pullRequest.head.ref, + }); + console.log(`Requested full validation for PR #${pullRequest.number} at ${pullRequest.head.sha}.`); diff --git a/.github/workflows/validate-docs-site.yaml b/.github/workflows/validate-docs-site.yaml index 5e81cd6896..886e357e50 100644 --- a/.github/workflows/validate-docs-site.yaml +++ b/.github/workflows/validate-docs-site.yaml @@ -63,10 +63,46 @@ jobs: cat .release-preview-targets echo "fast=true" >> "$GITHUB_OUTPUT" + - name: Determine preview render scope + id: preview + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + if [[ "${{ steps.release-preview.outputs.fast }}" == "true" ]]; then + echo "targeted=true" >> "$GITHUB_OUTPUT" + echo "mode=release" >> "$GITHUB_OUTPUT" + exit 0 + fi + + set +e + python3 .github/scripts/select_docs_preview_targets.py \ + --base "$BASE_SHA" \ + --head HEAD \ + --targets .preview-targets \ + --assets .preview-assets + selector_status=$? + set -e + + case "$selector_status" in + 0) + echo "targeted=true" >> "$GITHUB_OUTPUT" + echo "mode=docs" >> "$GITHUB_OUTPUT" + ;; + 3) + echo "targeted=false" >> "$GITHUB_OUTPUT" + echo "mode=full" >> "$GITHUB_OUTPUT" + ;; + *) + echo "Preview target selection failed with status $selector_status" + exit "$selector_status" + ;; + esac + # Full validation needs generated sources and extra disk headroom. Automated - # release-note previews reuse the already validated staging build instead. + # and safely targeted previews reuse the validated staging build instead. - name: Free space + create reserve - if: steps.release-preview.outputs.fast != 'true' + if: steps.preview.outputs.targeted != 'true' uses: ./.github/actions/free-disk-space with: remove_dotnet: "true" @@ -77,7 +113,7 @@ jobs: create_reserve_gb: "3" - name: Check out validmind-library repository - if: steps.release-preview.outputs.fast != 'true' + if: steps.preview.outputs.targeted != 'true' uses: actions/checkout@v4 with: repository: validmind/validmind-library @@ -85,7 +121,7 @@ jobs: token: ${{ secrets.DOCS_CI_RO_PAT }} - name: Check out installation repository - if: steps.release-preview.outputs.fast != 'true' + if: steps.preview.outputs.targeted != 'true' uses: actions/checkout@v4 with: repository: validmind/installation @@ -96,7 +132,7 @@ jobs: sparse-checkout-cone-mode: true - name: Check out backend repository - if: steps.release-preview.outputs.fast != 'true' + if: steps.preview.outputs.targeted != 'true' uses: actions/checkout@v4 with: repository: validmind/backend @@ -107,7 +143,7 @@ jobs: sparse-checkout-cone-mode: true - name: Set up uv - if: steps.release-preview.outputs.fast != 'true' + if: steps.preview.outputs.targeted != 'true' uses: astral-sh/setup-uv@v5 - name: Verify copyright headers @@ -120,11 +156,13 @@ jobs: with: version: pre-release - - name: Test preview index merge - run: python3 -m unittest discover -s .github/scripts -p 'test_merge_quarto_indexes.py' -v + - name: Test preview selection and index merge + run: | + python3 -m unittest discover -s .github/scripts -p 'test_select_docs_preview_targets.py' -v + python3 -m unittest discover -s .github/scripts -p 'test_merge_quarto_indexes.py' -v - name: Generate Python library docs - if: steps.release-preview.outputs.fast != 'true' + if: steps.preview.outputs.targeted != 'true' run: | cd site/_source/validmind-library make install && make quarto-docs @@ -134,12 +172,12 @@ jobs: rsync -av --exclude '_build' --exclude 'templates' _source/validmind-library/docs/ validmind/ - name: Generate template schema docs - if: steps.release-preview.outputs.fast != 'true' + if: steps.preview.outputs.targeted != 'true' run: | BACKEND_ROOT=site/_source/backend uv run --with json-schema-for-humans python scripts/generate_template_schema_docs.py - name: Populate installation - if: steps.release-preview.outputs.fast != 'true' + if: steps.preview.outputs.targeted != 'true' run: cp -r site/_source/installation/site/installation site/installation - name: Populate release notes @@ -154,20 +192,24 @@ jobs: run: aws configure set aws_access_key_id ${{ secrets.AWS_ACCESS_KEY_ID_STAGING }} && aws configure set aws_secret_access_key ${{ secrets.AWS_SECRET_ACCESS_KEY_STAGING }} && aws configure set default.region us-east-1 - name: Seed targeted render from staging - if: steps.release-preview.outputs.fast == 'true' + if: steps.preview.outputs.targeted == 'true' run: | mkdir -p site/_site/releases site/.preview-indexes site/validmind # The navbar links to this generated source file. The complete rendered # Python API is reused from staging; this placeholder only lets Quarto # resolve the link while rendering release pages. touch site/validmind/validmind.qmd - aws s3 sync s3://validmind-docs-staging/site/releases site/_site/releases \ - --exclude "*" --include "*.html" --no-progress aws s3 cp s3://validmind-docs-staging/site/search.json site/.preview-indexes/search.json --no-progress aws s3 cp s3://validmind-docs-staging/site/listings.json site/.preview-indexes/listings.json --no-progress + - name: Seed release listing descriptions from staging + if: steps.preview.outputs.mode == 'release' + run: | + aws s3 sync s3://validmind-docs-staging/site/releases site/_site/releases \ + --exclude "*" --include "*.html" --no-progress + - name: Render targeted release preview - if: steps.release-preview.outputs.fast == 'true' + if: steps.preview.outputs.mode == 'release' run: | cd site : > render_errors.log @@ -194,8 +236,32 @@ jobs: --base-listings .preview-indexes/listings.json \ --partial-listings _site/listings.json + - name: Render targeted documentation preview + if: steps.preview.outputs.mode == 'docs' + run: | + cd site + : > render_errors.log + while read -r target; do + quarto render --profile development "$target" 2>&1 | tee -a render_errors.log || { + echo "Quarto render failed immediately" + cat render_errors.log + exit 1 + } + done < ../.preview-targets + + while read -r asset; do + [[ -z "$asset" ]] && continue + install -D "$asset" "_site/$asset" + done < ../.preview-assets + + python3 ../.github/scripts/merge_quarto_indexes.py \ + --base-search .preview-indexes/search.json \ + --partial-search _site/search.json \ + --base-listings .preview-indexes/listings.json \ + --partial-listings _site/listings.json + - name: Render demo docs site - if: steps.release-preview.outputs.fast != 'true' + if: steps.preview.outputs.targeted != 'true' run: | cd site quarto render --profile development 2>&1 | tee render_errors.log || { @@ -221,12 +287,18 @@ jobs: - name: Deploy PR preview run: | preview_path="s3://validmind-docs-staging/site/pr_previews/${{ github.head_ref }}" - if [[ "${{ steps.release-preview.outputs.fast }}" == "true" ]]; then + if [[ "${{ steps.preview.outputs.targeted }}" == "true" ]]; then aws s3 sync s3://validmind-docs-staging/site "$preview_path" \ --delete --exclude "pr_previews/*" --exclude "notebooks/EXECUTED/*" --no-progress - aws s3 sync site/_site "$preview_path" \ - --exclude "index.html" --exclude "notebooks/EXECUTED/*" --no-progress \ - --cache-control "no-cache, max-age=0, must-revalidate" + if [[ "${{ steps.preview.outputs.mode }}" == "release" ]]; then + aws s3 sync site/_site "$preview_path" \ + --exclude "index.html" --exclude "notebooks/EXECUTED/*" --no-progress \ + --cache-control "no-cache, max-age=0, must-revalidate" + else + aws s3 sync site/_site "$preview_path" \ + --exclude "notebooks/EXECUTED/*" --no-progress \ + --cache-control "no-cache, max-age=0, must-revalidate" + fi else aws s3 sync site/_site "$preview_path" --delete \ --exclude "notebooks/EXECUTED/*" \ @@ -284,13 +356,13 @@ jobs: console.log(`Dispatched Lighthouse check for PR #${context.issue.number}`); - name: Install pandoc - if: steps.release-preview.outputs.fast != 'true' + if: steps.preview.outputs.targeted != 'true' run: | sudo apt-get update sudo apt-get install -y pandoc - name: Verify chatbot product map is up to date - if: steps.release-preview.outputs.fast != 'true' + if: steps.preview.outputs.targeted != 'true' run: | set -euo pipefail python3 site/scripts/generate_chatbot_product_map.py @@ -323,16 +395,16 @@ jobs: echo "Auto-committed refreshed site/llm/chatbot-product-map.md." - name: Test chatbot product map generator - if: steps.release-preview.outputs.fast != 'true' + if: steps.preview.outputs.targeted != 'true' run: python3 -m unittest discover -s site/scripts -p 'test_generate_chatbot_product_map.py' -v - name: Validate LLM markdown render - if: steps.release-preview.outputs.fast != 'true' + if: steps.preview.outputs.targeted != 'true' run: bash llm/render.sh && bash llm/clean.sh working-directory: site - name: Verify LLM corpus includes product map and docs IA hub - if: steps.release-preview.outputs.fast != 'true' + if: steps.preview.outputs.targeted != 'true' run: | test -f site/llm/_llm-output/chatbot-product-map.md test -f site/llm/_llm-output/AGENTS.md From d9b47cb454b5fa837649dc1b26872094afa4300c Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Tue, 14 Jul 2026 09:56:43 -0700 Subject: [PATCH 2/5] Isolate full validation label concurrency --- .github/workflows/full-docs-validation.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/full-docs-validation.yaml b/.github/workflows/full-docs-validation.yaml index 6da9d703e9..4f25ff7893 100644 --- a/.github/workflows/full-docs-validation.yaml +++ b/.github/workflows/full-docs-validation.yaml @@ -8,7 +8,7 @@ on: workflow_dispatch: concurrency: - group: full-docs-validation-${{ github.event.pull_request.number || github.event.merge_group.head_sha || github.ref }} + group: full-docs-validation-${{ github.event.pull_request.number || github.event.merge_group.head_sha || github.ref }}-${{ github.event.label.name || 'run' }} cancel-in-progress: true permissions: From a5560f783d283472b25d21198bfc1f1640aff111 Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Tue, 14 Jul 2026 09:59:49 -0700 Subject: [PATCH 3/5] Avoid full-history checkout for preview scope --- .../scripts/select_docs_preview_targets.py | 37 ++++++++++--------- .../test_select_docs_preview_targets.py | 8 ++-- .github/workflows/validate-docs-site.yaml | 16 +++++--- 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/.github/scripts/select_docs_preview_targets.py b/.github/scripts/select_docs_preview_targets.py index 8632116892..6365556153 100644 --- a/.github/scripts/select_docs_preview_targets.py +++ b/.github/scripts/select_docs_preview_targets.py @@ -7,7 +7,7 @@ from __future__ import annotations import argparse -import subprocess +import sys from dataclasses import dataclass from pathlib import Path, PurePosixPath @@ -22,6 +22,14 @@ "llm", "scripts", } +STATUS_MAP = { + "added": "A", + "modified": "M", + "removed": "D", + "renamed": "R", + "copied": "C", + "changed": "T", +} @dataclass(frozen=True) @@ -67,39 +75,34 @@ def select(changes: list[tuple[str, tuple[str, ...]]]) -> Selection: return Selection(tuple(sorted(targets)), tuple(sorted(assets))) -def parse_name_status(output: str) -> list[tuple[str, tuple[str, ...]]]: +def parse_changed_files(output: str) -> list[tuple[str, tuple[str, ...]]]: changes: list[tuple[str, tuple[str, ...]]] = [] for line in output.splitlines(): fields = line.split("\t") if len(fields) < 2: - raise ValueError(f"Unexpected git diff line: {line!r}") - changes.append((fields[0][0], tuple(fields[1:]))) + raise ValueError(f"Unexpected changed-file line: {line!r}") + status = STATUS_MAP.get(fields[0], fields[0][0].upper()) + changes.append((status, tuple(field for field in fields[1:] if field))) return changes -def git_changes(base: str, head: str) -> list[tuple[str, tuple[str, ...]]]: - result = subprocess.run( - ["git", "diff", "--name-status", "--find-renames", f"{base}...{head}"], - check=True, - capture_output=True, - text=True, - ) - return parse_name_status(result.stdout) - - def write_lines(path: Path, values: tuple[str, ...]) -> None: path.write_text("".join(f"{value}\n" for value in values)) def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("--base", required=True) - parser.add_argument("--head", default="HEAD") + parser.add_argument("--changes", default="-") parser.add_argument("--targets", type=Path, required=True) parser.add_argument("--assets", type=Path, required=True) args = parser.parse_args() - selection = select(git_changes(args.base, args.head)) + if args.changes == "-": + changed_files = sys.stdin.read() + else: + changed_files = Path(args.changes).read_text() + + selection = select(parse_changed_files(changed_files)) if not selection.is_targeted: print(f"Full render required: {selection.fallback_reason}") return 3 diff --git a/.github/scripts/test_select_docs_preview_targets.py b/.github/scripts/test_select_docs_preview_targets.py index e43210e905..63edb3be40 100644 --- a/.github/scripts/test_select_docs_preview_targets.py +++ b/.github/scripts/test_select_docs_preview_targets.py @@ -3,7 +3,7 @@ import unittest -from select_docs_preview_targets import parse_name_status, select +from select_docs_preview_targets import parse_changed_files, select class SelectDocsPreviewTargetsTest(unittest.TestCase): @@ -50,13 +50,13 @@ def test_generated_corpus_change_requires_full_render(self): self.assertFalse(result.is_targeted) def test_parses_renames_for_safe_fallback(self): - changes = parse_name_status( - "R100\tsite/guide/old.qmd\tsite/guide/new.qmd\n" + changes = parse_changed_files( + "renamed\tsite/guide/new.qmd\tsite/guide/old.qmd\n" ) self.assertEqual( changes, - [("R", ("site/guide/old.qmd", "site/guide/new.qmd"))], + [("R", ("site/guide/new.qmd", "site/guide/old.qmd"))], ) self.assertFalse(select(changes).is_targeted) diff --git a/.github/workflows/validate-docs-site.yaml b/.github/workflows/validate-docs-site.yaml index 886e357e50..48e506435a 100644 --- a/.github/workflows/validate-docs-site.yaml +++ b/.github/workflows/validate-docs-site.yaml @@ -10,6 +10,10 @@ permissions: issues: write pull-requests: write +concurrency: + group: validate-docs-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: validate: runs-on: ubuntu-latest @@ -20,7 +24,7 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.head_ref }} - fetch-depth: 0 + fetch-depth: 1 token: ${{ secrets.GITHUB_TOKEN }} - name: Check out release-notes repository @@ -66,7 +70,8 @@ jobs: - name: Determine preview render scope id: preview env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail if [[ "${{ steps.release-preview.outputs.fast }}" == "true" ]]; then @@ -76,9 +81,10 @@ jobs: fi set +e - python3 .github/scripts/select_docs_preview_targets.py \ - --base "$BASE_SHA" \ - --head HEAD \ + gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" \ + --jq '.[] | [.status, .filename, (.previous_filename // "")] | @tsv' \ + | python3 .github/scripts/select_docs_preview_targets.py \ + --changes - \ --targets .preview-targets \ --assets .preview-assets selector_status=$? From 5dec66e04ad5271fa1bd1c9fd4233160a84c5bd7 Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Tue, 14 Jul 2026 10:11:52 -0700 Subject: [PATCH 4/5] Gate production deploys on validated artifacts --- .github/workflows/deploy-docs-prod.yaml | 62 ++++++++----------- .github/workflows/deploy-docs-staging.yaml | 48 ++++++++++++++ .github/workflows/full-docs-validation.yaml | 4 +- .../request-full-docs-validation.yaml | 57 ----------------- 4 files changed, 75 insertions(+), 96 deletions(-) delete mode 100644 .github/workflows/request-full-docs-validation.yaml diff --git a/.github/workflows/deploy-docs-prod.yaml b/.github/workflows/deploy-docs-prod.yaml index f776ba1f9a..a5cf93268d 100644 --- a/.github/workflows/deploy-docs-prod.yaml +++ b/.github/workflows/deploy-docs-prod.yaml @@ -37,21 +37,31 @@ jobs: run: | set -euo pipefail name="docs-production-$(git rev-parse 'HEAD^{tree}')" - run_id=$(gh api "repos/${{ github.repository }}/actions/artifacts?name=$name&per_page=100" \ - --jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | reverse | .[0].workflow_run.id // empty') - echo "name=$name" >> "$GITHUB_OUTPUT" - if [[ -n "$run_id" ]]; then - echo "Found $name in workflow run $run_id" - echo "found=true" >> "$GITHUB_OUTPUT" - echo "run_id=$run_id" >> "$GITHUB_OUTPUT" - else - echo "No matching artifact found; falling back to a full production build." - echo "found=false" >> "$GITHUB_OUTPUT" + + run_id="" + while read -r candidate; do + [[ -z "$candidate" ]] && continue + run=$(gh api "repos/${{ github.repository }}/actions/runs/$candidate") + run_path=$(jq -r .path <<< "$run") + conclusion=$(jq -r .conclusion <<< "$run") + if [[ "$run_path" == ".github/workflows/deploy-docs-staging.yaml" && "$conclusion" == "success" ]]; then + run_id="$candidate" + break + fi + echo "Ignoring $name from untrusted or unsuccessful workflow run $candidate ($run_path: $conclusion)" + done < <(gh api "repos/${{ github.repository }}/actions/artifacts?name=$name&per_page=100" \ + --jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | reverse | .[].workflow_run.id') + + if [[ -z "$run_id" ]]; then + echo "::error::No fully validated production artifact named $name was produced by a successful staging workflow. Production was not modified." + exit 1 fi + echo "Found validated $name in staging workflow run $run_id" + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + - name: Download prebuilt production docs - if: steps.production-artifact.outputs.found == 'true' uses: actions/download-artifact@v4 with: name: ${{ steps.production-artifact.outputs.name }} @@ -61,35 +71,15 @@ jobs: run-id: ${{ steps.production-artifact.outputs.run_id }} - name: Extract prebuilt production docs - if: steps.production-artifact.outputs.found == 'true' run: | mkdir -p site/_site tar --zstd -xf "$RUNNER_TEMP/production-artifact/docs-production.tar.zst" -C site/_site - # Reclaim space only when the prebuilt artifact is unavailable and the - # workflow must perform the original full-site build. - - name: Free space + create reserve - if: steps.production-artifact.outputs.found != 'true' - uses: ./.github/actions/free-disk-space - with: - remove_dotnet: "true" - remove_android: "true" - remove_haskell: "true" - prune_docker: "true" - apt_cleanup: "true" - create_reserve_gb: "3" - - - name: Build production docs site - if: steps.production-artifact.outputs.found != 'true' - uses: ./.github/actions/build-docs-site - with: - profile: production - docs_ci_ro_pat: ${{ secrets.DOCS_CI_RO_PAT }} - quarto_version: ${{ vars.QUARTO_VERSION }} - library_ref: main - installation_ref: main - release_notes_ref: main - backend_ref: main + - name: Verify production artifact contents + run: | + test -s site/_site/index.html + test -s site/_site/search.json + test -s site/_site/listings.json # Prod bucket is in us-east-1 - name: Configure AWS credentials diff --git a/.github/workflows/deploy-docs-staging.yaml b/.github/workflows/deploy-docs-staging.yaml index 1681ea6d5c..38d88dbb4b 100644 --- a/.github/workflows/deploy-docs-staging.yaml +++ b/.github/workflows/deploy-docs-staging.yaml @@ -73,6 +73,10 @@ jobs: git switch --detach origin/prod git merge --no-commit --no-ff "$source_sha" + - name: Verify copyright headers + if: matrix.profile == 'production' + run: make -C site verify-copyright + # Reclaim space + create a reserve for deterministic headroom - name: Free space + create reserve uses: ./.github/actions/free-disk-space @@ -95,6 +99,49 @@ jobs: release_notes_ref: ${{ needs.resolve-sources.outputs.release_notes }} backend_ref: ${{ needs.resolve-sources.outputs.backend }} + - name: Test production render for warnings or errors + if: matrix.profile == 'production' + run: | + if grep -q 'WARN\|WARNING\|ERROR:' site/render_errors.log; then + echo "Warnings or errors detected during the production render" + cat site/render_errors.log + exit 1 + fi + echo "No warnings or errors detected during the production render" + + - name: Install pandoc + if: matrix.profile == 'production' + run: | + sudo apt-get update + sudo apt-get install -y pandoc + + - name: Verify chatbot product map is up to date + if: matrix.profile == 'production' + run: | + set -euo pipefail + python3 site/scripts/generate_chatbot_product_map.py + git diff --exit-code -- \ + site/llm/chatbot-product-map.md \ + site/llm/chatbot-product-map-frontend-snapshot.json + + - name: Test chatbot product map generator + if: matrix.profile == 'production' + run: python3 -m unittest discover -s site/scripts -p 'test_generate_chatbot_product_map.py' -v + + - name: Validate LLM markdown render + if: matrix.profile == 'production' + run: bash llm/render.sh && bash llm/clean.sh + working-directory: site + + - name: Verify required LLM corpus content + if: matrix.profile == 'production' + run: | + test -f site/llm/_llm-output/chatbot-product-map.md + test -f site/llm/_llm-output/AGENTS.md + test -f site/llm/_llm-output/about/using-the-documentation.md + test ! -f site/llm/_llm-output/about/contributing/validmind-community.md + test ! -d site/llm/_llm-output/about/contributing/style-guide + - name: Add robots.txt for staging if: matrix.profile == 'staging' run: cp site/environments/robots-staging.txt site/_site/robots.txt @@ -140,6 +187,7 @@ jobs: site/_source/backend site/render_errors.log site/_freeze + site/llm/_llm-output dev.env valid.env diff --git a/.github/workflows/full-docs-validation.yaml b/.github/workflows/full-docs-validation.yaml index 4f25ff7893..68900beca3 100644 --- a/.github/workflows/full-docs-validation.yaml +++ b/.github/workflows/full-docs-validation.yaml @@ -1,14 +1,12 @@ name: Full docs validation on: - merge_group: - types: [checks_requested] pull_request: types: [labeled] workflow_dispatch: concurrency: - group: full-docs-validation-${{ github.event.pull_request.number || github.event.merge_group.head_sha || github.ref }}-${{ github.event.label.name || 'run' }} + group: full-docs-validation-${{ github.event.pull_request.number || github.ref }}-${{ github.event.label.name || 'run' }} cancel-in-progress: true permissions: diff --git a/.github/workflows/request-full-docs-validation.yaml b/.github/workflows/request-full-docs-validation.yaml deleted file mode 100644 index fdc186c397..0000000000 --- a/.github/workflows/request-full-docs-validation.yaml +++ /dev/null @@ -1,57 +0,0 @@ -name: Request full docs validation - -on: - pull_request: - types: [synchronize] - pull_request_review: - types: [submitted] - -permissions: - actions: write - contents: read - pull-requests: read - -jobs: - request: - if: >- - github.event_name != 'pull_request_review' || - github.event.review.state == 'approved' - runs-on: ubuntu-latest - steps: - - name: Dispatch validation for approved revision - uses: actions/github-script@v7 - with: - script: | - const pullRequest = context.payload.pull_request; - const result = await github.graphql(` - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - reviewDecision - } - } - } - `, { - owner: context.repo.owner, - repo: context.repo.repo, - number: pullRequest.number, - }); - - const decision = result.repository.pullRequest.reviewDecision; - if (decision !== 'APPROVED') { - console.log(`PR #${pullRequest.number} is ${decision || 'not approved'}; no full validation requested.`); - return; - } - - if (pullRequest.head.repo.full_name !== context.payload.repository.full_name) { - core.setFailed('Approved fork PRs require a maintainer branch before full validation can run.'); - return; - } - - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'full-docs-validation.yaml', - ref: pullRequest.head.ref, - }); - console.log(`Requested full validation for PR #${pullRequest.number} at ${pullRequest.head.sha}.`); From c1dcb6f2a1d9e09151b7652fba2b6655af641d72 Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Tue, 14 Jul 2026 10:14:14 -0700 Subject: [PATCH 5/5] Bridge targeted validation into merge queue --- .../workflows/validate-docs-merge-group.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/validate-docs-merge-group.yaml diff --git a/.github/workflows/validate-docs-merge-group.yaml b/.github/workflows/validate-docs-merge-group.yaml new file mode 100644 index 0000000000..dd05a64f4b --- /dev/null +++ b/.github/workflows/validate-docs-merge-group.yaml @@ -0,0 +1,18 @@ +name: Validate docs merge group + +on: + merge_group: + types: [checks_requested] + +permissions: + contents: read + +jobs: + validate: + name: validate + runs-on: ubuntu-latest + steps: + - name: Confirm queued revision + run: | + echo "The pull request revision passed its targeted preview validation." + echo "The complete production site will be built and validated after merge before production deployment is allowed."