diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000000..42c5394a18 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../skills \ No newline at end of file diff --git a/.github/actions/build-docs-site/action.yml b/.github/actions/build-docs-site/action.yml new file mode 100644 index 0000000000..9aa4e187f8 --- /dev/null +++ b/.github/actions/build-docs-site/action.yml @@ -0,0 +1,114 @@ +name: Build docs site +description: Populate generated sources and render a complete documentation site + +inputs: + profile: + description: Quarto profile to render + required: true + docs_ci_ro_pat: + description: Read-only token for private source repositories + required: true + quarto_version: + description: Quarto version to install + required: true + library_ref: + description: validmind-library revision to build + required: true + installation_ref: + description: installation revision to build + required: true + release_notes_ref: + description: release-notes revision to build + required: true + backend_ref: + description: backend revision to build + required: true + +runs: + using: composite + steps: + - name: Check out validmind-library repository + uses: actions/checkout@v4 + with: + repository: validmind/validmind-library + ref: ${{ inputs.library_ref }} + path: site/_source/validmind-library + token: ${{ inputs.docs_ci_ro_pat }} + + - name: Check out installation repository + uses: actions/checkout@v4 + with: + repository: validmind/installation + ref: ${{ inputs.installation_ref }} + path: site/_source/installation + token: ${{ inputs.docs_ci_ro_pat }} + sparse-checkout: | + site/installation + sparse-checkout-cone-mode: true + + - name: Check out release-notes repository + uses: actions/checkout@v4 + with: + repository: validmind/release-notes + ref: ${{ inputs.release_notes_ref }} + path: site/_source/release-notes + token: ${{ inputs.docs_ci_ro_pat }} + sparse-checkout: | + releases + sparse-checkout-cone-mode: true + + - name: Check out backend repository + uses: actions/checkout@v4 + with: + repository: validmind/backend + ref: ${{ inputs.backend_ref }} + path: site/_source/backend + token: ${{ inputs.docs_ci_ro_pat }} + sparse-checkout: | + src/backend/templates/documentation/model_documentation + sparse-checkout-cone-mode: true + + - name: Set up Quarto + uses: quarto-dev/quarto-actions/setup@v2 + with: + version: ${{ inputs.quarto_version }} + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + + - name: Generate Python library docs + shell: bash + run: | + cd site/_source/validmind-library + make install && make quarto-docs + cd ../../ + rm -rf validmind + mkdir -p validmind + rsync -av --exclude '_build' --exclude 'templates' _source/validmind-library/docs/ validmind/ + + - name: Generate template schema docs + shell: bash + run: BACKEND_ROOT=site/_source/backend uv run --with json-schema-for-humans python scripts/generate_template_schema_docs.py + + - name: Populate installation + shell: bash + run: cp -r site/_source/installation/site/installation site/installation + + - name: Populate release notes + shell: bash + run: | + cp -r site/_source/release-notes/releases site + rm -f site/releases/backend-releases.qmd site/releases/cmvm-releases.qmd + + - name: Render docs site + shell: bash + env: + QUARTO_PROFILE: ${{ inputs.profile }} + run: | + cd site + quarto render --profile "$QUARTO_PROFILE" &> render_errors.log || { + echo "Quarto render failed immediately" + cat render_errors.log + exit 1 + } + make generate-sitemap diff --git a/.github/scripts/merge_quarto_indexes.py b/.github/scripts/merge_quarto_indexes.py new file mode 100644 index 0000000000..cc574a7fff --- /dev/null +++ b/.github/scripts/merge_quarto_indexes.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +"""Merge Quarto indexes from a partial render into full-site indexes.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +def merge_items( + base: list[dict[str, Any]], partial: list[dict[str, Any]], key: str +) -> list[dict[str, Any]]: + """Return base items with partial items replacing entries with the same key.""" + merged: dict[str, dict[str, Any]] = {} + for item in [*base, *partial]: + value = item.get(key) + if not isinstance(value, str) or not value: + raise ValueError(f"Every index item must have a non-empty {key!r}") + merged[value] = item + return list(merged.values()) + + +def merge_file(base_path: Path, partial_path: Path, key: str) -> None: + base = json.loads(base_path.read_text()) + partial = json.loads(partial_path.read_text()) + if not isinstance(base, list) or not isinstance(partial, list): + raise ValueError("Quarto indexes must contain JSON arrays") + partial_path.write_text( + json.dumps(merge_items(base, partial, key), separators=(",", ":")) + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--base-search", type=Path, required=True) + parser.add_argument("--partial-search", type=Path, required=True) + parser.add_argument("--base-listings", type=Path, required=True) + parser.add_argument("--partial-listings", type=Path, required=True) + args = parser.parse_args() + + merge_file(args.base_search, args.partial_search, "objectID") + merge_file(args.base_listings, args.partial_listings, "listing") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/select_docs_preview_targets.py b/.github/scripts/select_docs_preview_targets.py new file mode 100644 index 0000000000..6365556153 --- /dev/null +++ b/.github/scripts/select_docs_preview_targets.py @@ -0,0 +1,121 @@ +#!/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 sys +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", +} +STATUS_MAP = { + "added": "A", + "modified": "M", + "removed": "D", + "renamed": "R", + "copied": "C", + "changed": "T", +} + + +@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_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 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 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("--changes", default="-") + parser.add_argument("--targets", type=Path, required=True) + parser.add_argument("--assets", type=Path, required=True) + args = parser.parse_args() + + 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 + + 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_merge_quarto_indexes.py b/.github/scripts/test_merge_quarto_indexes.py new file mode 100644 index 0000000000..f2b450ecfd --- /dev/null +++ b/.github/scripts/test_merge_quarto_indexes.py @@ -0,0 +1,49 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +import json +import tempfile +import unittest +from pathlib import Path + +from merge_quarto_indexes import merge_file, merge_items + + +class MergeQuartoIndexesTest(unittest.TestCase): + def test_partial_items_replace_matching_base_items(self): + base = [{"objectID": "old", "text": "keep"}, {"objectID": "changed", "text": "old"}] + partial = [{"objectID": "changed", "text": "new"}, {"objectID": "new", "text": "add"}] + + self.assertEqual( + merge_items(base, partial, "objectID"), + [ + {"objectID": "old", "text": "keep"}, + {"objectID": "changed", "text": "new"}, + {"objectID": "new", "text": "add"}, + ], + ) + + def test_merge_file_updates_partial_path(self): + with tempfile.TemporaryDirectory() as directory: + base_path = Path(directory) / "base.json" + partial_path = Path(directory) / "partial.json" + base_path.write_text(json.dumps([{"listing": "/old", "items": ["a"]}])) + partial_path.write_text(json.dumps([{"listing": "/new", "items": ["b"]}])) + + merge_file(base_path, partial_path, "listing") + + self.assertEqual( + json.loads(partial_path.read_text()), + [ + {"listing": "/old", "items": ["a"]}, + {"listing": "/new", "items": ["b"]}, + ], + ) + + def test_missing_key_is_rejected(self): + with self.assertRaises(ValueError): + merge_items([], [{"text": "missing object id"}], "objectID") + + +if __name__ == "__main__": + unittest.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..63edb3be40 --- /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_changed_files, 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_changed_files( + "renamed\tsite/guide/new.qmd\tsite/guide/old.qmd\n" + ) + + self.assertEqual( + changes, + [("R", ("site/guide/new.qmd", "site/guide/old.qmd"))], + ) + self.assertFalse(select(changes).is_targeted) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ai_explain.py b/.github/workflows/ai_explain.py deleted file mode 100644 index 4aa79fa985..0000000000 --- a/.github/workflows/ai_explain.py +++ /dev/null @@ -1,125 +0,0 @@ -import json -import os -import sys - -from github import Github -from openai import APIConnectionError, OpenAI, OpenAIError - - -# Initialize GitHub and OpenAI clients -github_token = os.getenv("GITHUB_TOKEN") -repo_name = os.getenv("GITHUB_REPOSITORY") -pr_ref = os.getenv("GITHUB_REF") - -# Extract PR number from the ref (refs/pull/{pr_number}/merge) -pr_number = pr_ref.split("/")[2] - -g = Github(github_token) -repo = g.get_repo(repo_name) -pr = repo.get_pull(int(pr_number)) - -# Get the files changed in the current PR -files = pr.get_files() -diffs = [] - -for file in files: - filename = file.filename - patch = file.patch - diffs.append(f"File: {filename}\n{patch}") - -diff = "\n\n".join(diffs) - -# Add a unique marker at the start of the comment to find comments by the bot -COMMENT_MARKER = "" - -# Fetch existing AI explanation comment -existing_explanation_comments = [] -comments = pr.get_issue_comments() -for comment in comments: - if comment.user.login == "github-actions[bot]" and COMMENT_MARKER in comment.body: - existing_explanation_comments.append(comment) - -# OpenAI prompt template -prompt_template = """ -You are an expert software engineer reviewing a pull request (PR) that introduces enhancements or -bugfixes to a software project. Your task is to assess the changes made in the PR and provide a -detailed summary, test suggestions, code quality assessment, and security assessment. - -To produce an assessment that can be processed with a script, you generate a JSON object that -describes a PR diff. Your response should be a JSON object with the following keys: - -- title (string) -- summary (markdown string that starts with the title "# PR Summary") -- test_suggestions (array of strings) -- code_quality_assessment (array of strings) -- security_assessment (array of strings) - -## Instructions for Computing the value of each field - -- The `title` field should be a concise summary of the changes. -- The `summary` field should provide a detailed markdown description of the PR, titled "# PR Summary". - - The `summary` should focus on the functional changes introduced by the PR. - - The `summary` should omit mentioning version updates from files like `pyproject.toml` or `package.json`, formatting changes, or other trivial modifications. -- The `test_suggestions` field should list test suggestions as an array of strings. -- The `code_quality_assessment` field should provide an assessment of the code quality as an array of strings. It can be "None" if there are no specific concerns. -- The `security_assessment` field should provide an assessment of the security implications as an array of strings. It can be "None" if there are no specific concerns. - -diff: -``` -{diff} -``` -""" - -# Prepare OpenAI prompt -prompt = prompt_template.format(diff=diff) - -try: - # Call OpenAI API - client = OpenAI() - response = client.chat.completions.create( - model="o3-mini", - response_format={"type": "json_object"}, - messages=[ - { - "role": "user", - "content": prompt, - } - ], - ) -except APIConnectionError as e: - print(f"OpenAI API connection error: {e}") - sys.exit(0) # happy exit so that the workflow will continue -except OpenAIError as e: - print(f"OpenAI API error: {e}") - sys.exit(0) # happy exit so that the workflow will continue -except Exception as e: - print(f"Unexpected error: {e}") - sys.exit(0) # happy exit so that the workflow will continue - -# Parse OpenAI response -ai_response = json.loads(response.choices[0].message.content.strip()) - -# Create a new comment and delete the existing explanation comment -new_comment = pr.create_issue_comment( - f"{COMMENT_MARKER}\n" - f"{ai_response['summary']}\n\n" - f"## Test Suggestions\n" - f"- " + "\n- ".join(ai_response["test_suggestions"]) - if ai_response.get("test_suggestions", None) - else ( - "n/a" + "\n\n## Code Quality Assessment\n- " + "\n- ".join(ai_response["code_quality_assessment"]) - if ai_response.get("code_quality_assessment", None) - else ( - "n/a" + "\n\n## Security Assessment\n- " + "\n- ".join(ai_response["security_assessment"]) - if ai_response.get("security_assessment", None) - else "n/a" + "\n\n" - ) - ) -) - -# Delete all previous AI explain comments -for comment in existing_explanation_comments: - try: - comment.delete() - except Exception as e: - print(f"Failed to delete comment: {e!s}") diff --git a/.github/workflows/ai_explain.yaml b/.github/workflows/ai_explain.yaml deleted file mode 100644 index 587ff35acd..0000000000 --- a/.github/workflows/ai_explain.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: PR Explanation - -permissions: - contents: write - pull-requests: write - -on: - pull_request: - types: [opened, synchronize] - -jobs: - explain-pr: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install openai - pip install PyGithub - - - name: Explain PR - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENAI_API_KEY: ${{ secrets.OPENAI_PR_SUMMARY_KEY }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_REF: ${{ github.ref }} - run: python .github/workflows/ai_explain.py diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 3dc09acb0a..0000000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Claude Code Review - -on: - # workflow_dispatch: # Manual only - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 79fe056478..0000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' - diff --git a/.github/workflows/deploy-docs-prod.yaml b/.github/workflows/deploy-docs-prod.yaml index 29390293db..a5cf93268d 100644 --- a/.github/workflows/deploy-docs-prod.yaml +++ b/.github/workflows/deploy-docs-prod.yaml @@ -10,6 +10,14 @@ on: - completed workflow_dispatch: +concurrency: + group: deploy-docs-production + cancel-in-progress: true + +permissions: + actions: read + contents: read + jobs: deploy: if: | @@ -22,95 +30,56 @@ jobs: - name: Check out documentation repository uses: actions/checkout@v4 - # Reclaim space + create a reserve for deterministic headroom - - name: Free space + create reserve - 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: Check out validmind-library repository - uses: actions/checkout@v4 - with: - repository: validmind/validmind-library - path: site/_source/validmind-library - token: ${{ secrets.DOCS_CI_RO_PAT }} - - - name: Check out installation repository - uses: actions/checkout@v4 - with: - repository: validmind/installation - path: site/_source/installation - token: ${{ secrets.DOCS_CI_RO_PAT }} - sparse-checkout: | - site/installation - sparse-checkout-cone-mode: true - - - name: Check out release-notes repository - uses: actions/checkout@v4 - with: - repository: validmind/release-notes - path: site/_source/release-notes - token: ${{ secrets.DOCS_CI_RO_PAT }} - sparse-checkout: | - releases - sparse-checkout-cone-mode: true - - - name: Check out backend repository - uses: actions/checkout@v4 - with: - repository: validmind/backend - path: site/_source/backend - token: ${{ secrets.DOCS_CI_RO_PAT }} - sparse-checkout: | - src/backend/templates/documentation/model_documentation - sparse-checkout-cone-mode: true - - - name: Set up Quarto - uses: quarto-dev/quarto-actions/setup@v2 - with: - version: ${{ vars.QUARTO_VERSION }} - - - name: Set up uv - uses: astral-sh/setup-uv@v5 - - - name: Set up uv - uses: astral-sh/setup-uv@v5 - - - name: Generate Python library docs - run: | - cd site/_source/validmind-library - make install && make quarto-docs - cd ../../ - rm -rf validmind - mkdir -p validmind - rsync -av --exclude '_build' --exclude 'templates' _source/validmind-library/docs/ validmind/ - - - name: Generate template schema docs + - name: Find production artifact for this source tree + id: production-artifact + env: + GH_TOKEN: ${{ github.token }} run: | - BACKEND_ROOT=site/_source/backend uv run --with json-schema-for-humans python scripts/generate_template_schema_docs.py - - - name: Populate installation - run: cp -r site/_source/installation/site/installation site/installation + set -euo pipefail + name="docs-production-$(git rev-parse 'HEAD^{tree}')" + echo "name=$name" >> "$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 + uses: actions/download-artifact@v4 + with: + name: ${{ steps.production-artifact.outputs.name }} + path: ${{ runner.temp }}/production-artifact + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ steps.production-artifact.outputs.run_id }} - - name: Populate release notes + - name: Extract prebuilt production docs run: | - cp -r site/_source/release-notes/releases site - rm -f site/releases/backend-releases.qmd site/releases/cmvm-releases.qmd + mkdir -p site/_site + tar --zstd -xf "$RUNNER_TEMP/production-artifact/docs-production.tar.zst" -C site/_site - - name: Render prod docs site + - name: Verify production artifact contents run: | - cd site - quarto render --profile production &> render_errors.log || { - echo "Quarto render failed immediately"; - cat render_errors.log; - exit 1; - } - make generate-sitemap + 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 @@ -136,4 +105,4 @@ jobs: - name: Final disk usage if: always() - run: df -hT / \ No newline at end of file + run: df -hT / diff --git a/.github/workflows/deploy-docs-staging.yaml b/.github/workflows/deploy-docs-staging.yaml index 2ade3f3aa1..872c378bf1 100644 --- a/.github/workflows/deploy-docs-staging.yaml +++ b/.github/workflows/deploy-docs-staging.yaml @@ -1,5 +1,7 @@ name: Deploy docs site to staging +run-name: Deploy docs site to staging | ${{ github.event.workflow_run.id || github.sha }} + on: push: branches: @@ -10,17 +12,72 @@ on: - completed workflow_dispatch: +concurrency: + group: deploy-docs-staging + cancel-in-progress: true + jobs: - deploy: + resolve-sources: + name: Resolve source revisions if: | github.event_name == 'push' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + outputs: + library: ${{ steps.refs.outputs.library }} + installation: ${{ steps.refs.outputs.installation }} + release_notes: ${{ steps.refs.outputs.release_notes }} + backend: ${{ steps.refs.outputs.backend }} + steps: + - name: Resolve source revisions + id: refs + env: + GH_TOKEN: ${{ secrets.DOCS_CI_RO_PAT }} + run: | + echo "library=$(gh api repos/validmind/validmind-library/commits/main --jq .sha)" >> "$GITHUB_OUTPUT" + echo "installation=$(gh api repos/validmind/installation/commits/main --jq .sha)" >> "$GITHUB_OUTPUT" + echo "release_notes=$(gh api repos/validmind/release-notes/commits/main --jq .sha)" >> "$GITHUB_OUTPUT" + echo "backend=$(gh api repos/validmind/backend/commits/main --jq .sha)" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.profile }} docs site + if: | + github.event_name == 'push' || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') || + github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + needs: resolve-sources + strategy: + fail-fast: false + matrix: + profile: [staging, production] steps: - name: Check out documentation repository uses: actions/checkout@v4 + with: + # The production build predicts the staging-to-prod merge tree and + # therefore needs the shared branch history, not a depth-one clone. + fetch-depth: 0 + + # Production can contain hotfixes that have not flowed back to staging. + # Build the artifact from the tree that a staging-to-prod merge will + # produce, so its key matches the eventual prod merge commit. + - name: Prepare prospective production tree + if: matrix.profile == 'production' + run: | + set -euo pipefail + source_sha=$(git rev-parse HEAD) + git fetch origin prod + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + 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 @@ -33,95 +90,93 @@ jobs: apt_cleanup: "true" create_reserve_gb: "3" - - name: Check out validmind-library repository - uses: actions/checkout@v4 - with: - repository: validmind/validmind-library - path: site/_source/validmind-library - token: ${{ secrets.DOCS_CI_RO_PAT }} - - - name: Check out installation repository - uses: actions/checkout@v4 - with: - repository: validmind/installation - path: site/_source/installation - token: ${{ secrets.DOCS_CI_RO_PAT }} - sparse-checkout: | - site/installation - sparse-checkout-cone-mode: true - - - name: Check out release-notes repository - uses: actions/checkout@v4 - with: - repository: validmind/release-notes - path: site/_source/release-notes - token: ${{ secrets.DOCS_CI_RO_PAT }} - sparse-checkout: | - releases - sparse-checkout-cone-mode: true - - - name: Check out backend repository - uses: actions/checkout@v4 - with: - repository: validmind/backend - path: site/_source/backend - token: ${{ secrets.DOCS_CI_RO_PAT }} - sparse-checkout: | - src/backend/templates/documentation/model_documentation - sparse-checkout-cone-mode: true - - - name: Set up Quarto - uses: quarto-dev/quarto-actions/setup@v2 + - name: Build docs site + uses: ./.github/actions/build-docs-site with: - version: ${{ vars.QUARTO_VERSION }} - - - name: Set up uv - uses: astral-sh/setup-uv@v5 - - - name: Set up uv - uses: astral-sh/setup-uv@v5 - - - name: Generate Python library docs + profile: ${{ matrix.profile }} + docs_ci_ro_pat: ${{ secrets.DOCS_CI_RO_PAT }} + quarto_version: ${{ vars.QUARTO_VERSION }} + library_ref: ${{ needs.resolve-sources.outputs.library }} + installation_ref: ${{ needs.resolve-sources.outputs.installation }} + 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: | - cd site/_source/validmind-library - make install && make quarto-docs - cd ../../ - rm -rf validmind - mkdir -p validmind - rsync -av --exclude '_build' --exclude 'templates' _source/validmind-library/docs/ validmind/ - - - name: Generate template schema docs + 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: | - BACKEND_ROOT=site/_source/backend uv run --with json-schema-for-humans python scripts/generate_template_schema_docs.py - - - name: Populate installation - run: cp -r site/_source/installation/site/installation site/installation + sudo apt-get update + sudo apt-get install -y pandoc - - name: Populate release notes + - name: Verify chatbot product map is up to date + if: matrix.profile == 'production' run: | - cp -r site/_source/release-notes/releases site - rm -f site/releases/backend-releases.qmd site/releases/cmvm-releases.qmd - - - name: Render staging docs site + 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: | - cd site - quarto render --profile staging &> render_errors.log || { - echo "Quarto render failed immediately"; - cat render_errors.log; - exit 1; - } - make generate-sitemap + 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 # Staging bucket is in us-west-2 - name: Configure AWS credentials + if: matrix.profile == 'staging' 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-west-2 - name: Deploy docs staging site + if: matrix.profile == 'staging' run: aws s3 sync site/_site s3://validmind-docs-staging/site --delete --exclude "installation/helm-repo/*" --exclude "pr_previews/*" --exclude "notebooks/EXECUTED/*" --exclude "llm/*" --cache-control "no-cache, max-age=0, must-revalidate" && aws cloudfront create-invalidation --distribution-id ESWVTZYFL873V --paths "/*" --no-cli-pager + - name: Determine production artifact name + if: matrix.profile == 'production' + id: production-artifact + shell: bash + run: echo "name=docs-production-$(git write-tree)" >> "$GITHUB_OUTPUT" + + - name: Package production docs site + if: matrix.profile == 'production' + run: tar --zstd -cf "$RUNNER_TEMP/docs-production.tar.zst" -C site/_site . + + - name: Upload production docs artifact + if: matrix.profile == 'production' + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.production-artifact.outputs.name }} + path: ${{ runner.temp }}/docs-production.tar.zst + compression-level: 0 + if-no-files-found: error + retention-days: 14 + # Release headroom and shrink before final lightweight steps & post-job - name: Release reserve & shrink if: always() @@ -134,9 +189,10 @@ jobs: site/_source/backend site/render_errors.log site/_freeze + site/llm/_llm-output dev.env valid.env - name: Final disk usage if: always() - run: df -hT / \ No newline at end of file + run: df -hT / diff --git a/.github/workflows/full-docs-validation.yaml b/.github/workflows/full-docs-validation.yaml new file mode 100644 index 0000000000..68900beca3 --- /dev/null +++ b/.github/workflows/full-docs-validation.yaml @@ -0,0 +1,130 @@ +name: Full docs validation + +on: + pull_request: + types: [labeled] + workflow_dispatch: + +concurrency: + group: full-docs-validation-${{ github.event.pull_request.number || github.ref }}-${{ github.event.label.name || 'run' }} + cancel-in-progress: true + +permissions: + actions: write + contents: read + +jobs: + full-docs-validation: + name: Full docs validation + if: github.event_name != 'pull_request' || github.event.label.name == 'full-validation' + runs-on: ubuntu-latest + + steps: + - name: Check out documentation repository + uses: actions/checkout@v4 + + - name: Resolve source revisions + id: sources + env: + GH_TOKEN: ${{ secrets.DOCS_CI_RO_PAT }} + run: | + echo "library=$(gh api repos/validmind/validmind-library/commits/main --jq .sha)" >> "$GITHUB_OUTPUT" + echo "installation=$(gh api repos/validmind/installation/commits/main --jq .sha)" >> "$GITHUB_OUTPUT" + echo "release_notes=$(gh api repos/validmind/release-notes/commits/main --jq .sha)" >> "$GITHUB_OUTPUT" + echo "backend=$(gh api repos/validmind/backend/commits/main --jq .sha)" >> "$GITHUB_OUTPUT" + + - name: Free space + create reserve + 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: Verify copyright headers + run: make -C site verify-copyright + + - name: Build complete production docs site + 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: ${{ steps.sources.outputs.library }} + installation_ref: ${{ steps.sources.outputs.installation }} + release_notes_ref: ${{ steps.sources.outputs.release_notes }} + backend_ref: ${{ steps.sources.outputs.backend }} + + - name: Test for render warnings or errors + run: | + if grep -q 'WARN\|WARNING\|ERROR:' site/render_errors.log; then + echo "Warnings or errors detected during Quarto render" + cat site/render_errors.log + exit 1 + fi + echo "No warnings or errors detected during Quarto render" + + - name: Install pandoc + run: | + sudo apt-get update + sudo apt-get install -y pandoc + + - name: Verify chatbot product map is up to date + 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 + run: python3 -m unittest discover -s site/scripts -p 'test_generate_chatbot_product_map.py' -v + + - name: Validate LLM markdown render + run: bash llm/render.sh && bash llm/clean.sh + working-directory: site + + - name: Verify required LLM corpus content + 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: Determine artifact name + id: artifact + run: echo "name=docs-production-$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" + + - name: Package validated production site + run: tar --zstd -cf "$RUNNER_TEMP/docs-production.tar.zst" -C site/_site . + + - name: Upload validated production artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.artifact.outputs.name }} + path: ${{ runner.temp }}/docs-production.tar.zst + compression-level: 0 + if-no-files-found: error + retention-days: 14 + + - name: Release reserve and shrink + if: always() + uses: ./.github/actions/free-disk-space + with: + release_reserve: "true" + remove_paths: | + site/_source/installation + site/_source/release-notes + site/_source/backend + site/render_errors.log + site/_freeze + site/llm/_llm-output + dev.env + valid.env + + - name: Final disk usage + if: always() + run: df -hT / diff --git a/.github/workflows/merge-main-into-staging.yaml b/.github/workflows/merge-main-into-staging.yaml index fd825af4e6..d132355b18 100644 --- a/.github/workflows/merge-main-into-staging.yaml +++ b/.github/workflows/merge-main-into-staging.yaml @@ -1,9 +1,13 @@ name: Merge main into staging +run-name: Merge main into staging | ${{ github.event.client_payload.correlation_id || github.sha }} + on: push: branches: - main + repository_dispatch: + types: [release-notes-published] permissions: contents: write @@ -77,4 +81,4 @@ jobs: if: ${{ success() && steps.pr-number.outputs.pull-request-number != '' }} run: gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/update-staging-${{ github.run_id }}" env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-notes-preview.yaml b/.github/workflows/release-notes-preview.yaml new file mode 100644 index 0000000000..cdb002cf11 --- /dev/null +++ b/.github/workflows/release-notes-preview.yaml @@ -0,0 +1,224 @@ +name: Release notes preview + +run-name: Release notes preview | ${{ github.event.client_payload.correlation_id || inputs.correlation_id }} + +on: + workflow_call: + inputs: + release_notes_sha: + description: Immutable commit from validmind/release-notes + required: true + type: string + release_notes_ref: + description: Human-readable source branch + required: true + type: string + release_notes_pr: + description: Pull request number in validmind/release-notes + required: true + type: string + correlation_id: + description: Unique identifier used by callers to find this run + required: true + type: string + outputs: + preview_url: + description: Deployed documentation preview URL + value: ${{ jobs.preview.outputs.preview_url }} + secrets: + docs_ci_ro_pat: + required: true + aws_access_key_id_staging: + required: true + aws_secret_access_key_staging: + required: true + repository_dispatch: + types: [release-notes-preview] + +permissions: + contents: read + +concurrency: + group: release-notes-preview-${{ github.event.client_payload.release_notes_pr || inputs.release_notes_pr }} + cancel-in-progress: true + +jobs: + preview: + runs-on: ubuntu-latest + outputs: + preview_url: ${{ steps.source.outputs.preview_url }} + steps: + - name: Normalize and validate source request + id: source + env: + CALL_SHA: ${{ inputs.release_notes_sha }} + CALL_REF: ${{ inputs.release_notes_ref }} + CALL_PR: ${{ inputs.release_notes_pr }} + DISPATCH_SHA: ${{ github.event.client_payload.release_notes_sha }} + DISPATCH_REF: ${{ github.event.client_payload.release_notes_ref }} + DISPATCH_PR: ${{ github.event.client_payload.release_notes_pr }} + GH_TOKEN: ${{ secrets.DOCS_CI_RO_PAT || secrets.docs_ci_ro_pat }} + run: | + set -euo pipefail + sha="${DISPATCH_SHA:-$CALL_SHA}" + ref="${DISPATCH_REF:-$CALL_REF}" + pr="${DISPATCH_PR:-$CALL_PR}" + + [[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid release-notes SHA"; exit 1; } + [[ "$pr" =~ ^[0-9]+$ ]] || { echo "Invalid release-notes PR number"; exit 1; } + [[ "$ref" == automated/* || "$ref" == codex/* ]] || { + echo "Release-notes preview refs must use an approved branch prefix" + exit 1 + } + + actual_sha=$(gh api "repos/validmind/release-notes/pulls/$pr" --jq .head.sha) + actual_ref=$(gh api "repos/validmind/release-notes/pulls/$pr" --jq .head.ref) + actual_repo=$(gh api "repos/validmind/release-notes/pulls/$pr" --jq .head.repo.full_name) + state=$(gh api "repos/validmind/release-notes/pulls/$pr" --jq .state) + [[ "$actual_sha" == "$sha" && "$actual_ref" == "$ref" ]] || { + echo "Dispatch payload does not match release-notes PR #$pr" + exit 1 + } + [[ "$actual_repo" == "validmind/release-notes" && "$state" == "open" ]] || { + echo "Only open, same-repository release-notes PRs may deploy previews" + exit 1 + } + + preview_key="release-notes/pr-$pr" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "ref=$ref" >> "$GITHUB_OUTPUT" + echo "pr=$pr" >> "$GITHUB_OUTPUT" + echo "preview_key=$preview_key" >> "$GITHUB_OUTPUT" + echo "preview_url=https://docs-staging.validmind.ai/pr_previews/$preview_key/index.html" >> "$GITHUB_OUTPUT" + + - name: Check out documentation repository + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 1 + + - name: Check out release-notes revision + uses: actions/checkout@v4 + with: + repository: validmind/release-notes + ref: ${{ steps.source.outputs.sha }} + path: site/_source/release-notes + token: ${{ secrets.DOCS_CI_RO_PAT || secrets.docs_ci_ro_pat }} + sparse-checkout: | + releases + previews + sparse-checkout-cone-mode: true + + - name: Select changed release directories + env: + GH_TOKEN: ${{ secrets.DOCS_CI_RO_PAT || secrets.docs_ci_ro_pat }} + run: | + set -euo pipefail + git -C site/_source/release-notes fetch --depth=1 origin main + git -C site/_source/release-notes diff --name-only --diff-filter=ACMRT \ + FETCH_HEAD HEAD -- releases previews \ + | awk -F/ ' + $1 == "releases" && NF >= 4 { print $1 "/" $2 "/" $3 } + $1 == "previews" && NF >= 3 { print $1 "/" $2 } + ' \ + | sort -u > .release-preview-targets + if [[ ! -s .release-preview-targets ]]; then + echo "No changed release or preview directories found" + exit 1 + fi + echo "Targeted release and preview directories:" + cat .release-preview-targets + + - name: Verify copyright headers + run: make -C site verify-copyright + + - name: Set up Quarto + uses: quarto-dev/quarto-actions/setup@v2 + 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: Populate release notes + run: | + cp -r site/_source/release-notes/releases site + if [[ -d site/_source/release-notes/previews ]]; then + cp -r site/_source/release-notes/previews site + fi + rm -f site/releases/backend-releases.qmd site/releases/cmvm-releases.qmd + + - name: Configure AWS credentials + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_STAGING || secrets.aws_access_key_id_staging }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY_STAGING || secrets.aws_secret_access_key_staging }} + run: | + aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" + aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" + aws configure set default.region us-east-1 + + - name: Seed targeted render from staging + run: | + mkdir -p site/_site/releases site/.preview-indexes site/validmind + touch site/validmind/validmind.qmd + 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 + aws s3 sync s3://validmind-docs-staging/site/releases site/_site/releases \ + --exclude "*" --include "*.html" --no-progress + + - name: Render targeted release preview + run: | + set -euo pipefail + cd site + : > render_errors.log + while read -r target; do + quarto render --profile development "$target" 2>&1 | tee -a render_errors.log + done < ../.release-preview-targets + + for target in releases/*.qmd; do + [[ "$(basename "$target")" == _* ]] && continue + quarto render --profile development "$target" 2>&1 | tee -a render_errors.log + done + + 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: Add robots.txt + run: cp site/environments/robots-staging.txt site/_site/robots.txt + + - name: Test for warnings or errors + run: | + if grep -q 'WARN\|WARNING\|ERROR:' site/render_errors.log; then + echo "Warnings or errors detected during Quarto render" + cat site/render_errors.log + exit 1 + fi + echo "No warnings or errors detected during Quarto render" + + - name: Deploy release-notes preview + env: + PREVIEW_KEY: ${{ steps.source.outputs.preview_key }} + run: | + preview_path="s3://validmind-docs-staging/site/pr_previews/$PREVIEW_KEY" + 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" + aws cloudfront create-invalidation \ + --distribution-id ESWVTZYFL873V --paths "/*" --no-cli-pager + + - name: Publish workflow summary + env: + PREVIEW_URL: ${{ steps.source.outputs.preview_url }} + RELEASE_NOTES_PR: ${{ steps.source.outputs.pr }} + run: | + { + echo "## Release notes preview" + echo + echo "- Source: validmind/release-notes#$RELEASE_NOTES_PR" + echo "- Preview: $PREVIEW_URL" + } >> "$GITHUB_STEP_SUMMARY" 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." diff --git a/.github/workflows/validate-docs-site.yaml b/.github/workflows/validate-docs-site.yaml index 8f6dfaf9ad..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,11 +24,91 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.head_ref }} - fetch-depth: 0 + fetch-depth: 1 token: ${{ secrets.GITHUB_TOKEN }} - # Reclaim space + create a reserve for deterministic headroom + - name: Check out release-notes repository + uses: actions/checkout@v4 + with: + repository: validmind/release-notes + path: site/_source/release-notes + token: ${{ secrets.DOCS_CI_RO_PAT }} + sparse-checkout: | + releases + sparse-checkout-cone-mode: true + + - name: Detect automated release-notes preview + id: release-preview + shell: bash + run: | + set -euo pipefail + release_ref=$(git -C site/_source/release-notes for-each-ref \ + --format='%(refname:strip=3)' --points-at HEAD refs/remotes/origin | head -n 1) + + if [[ "$release_ref" != automated/* ]]; then + echo "fast=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git -C site/_source/release-notes fetch --depth=1 origin main + : > .release-preview-targets + git -C site/_source/release-notes diff --name-only --diff-filter=ACMRT FETCH_HEAD HEAD -- releases \ + | awk -F/ 'NF >= 4 { print $1 "/" $2 "/" $3 }' \ + | sort -u > .release-preview-targets + + if [[ ! -s .release-preview-targets ]]; then + echo "No release directories changed; falling back to full validation." + echo "fast=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Automated release-notes ref: $release_ref" + echo "Targeted render directories:" + cat .release-preview-targets + echo "fast=true" >> "$GITHUB_OUTPUT" + + - name: Determine preview render scope + id: preview + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + 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 + 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=$? + 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 + # and safely targeted previews reuse the validated staging build instead. - name: Free space + create reserve + if: steps.preview.outputs.targeted != 'true' uses: ./.github/actions/free-disk-space with: remove_dotnet: "true" @@ -35,6 +119,7 @@ jobs: create_reserve_gb: "3" - name: Check out validmind-library repository + if: steps.preview.outputs.targeted != 'true' uses: actions/checkout@v4 with: repository: validmind/validmind-library @@ -42,6 +127,7 @@ jobs: token: ${{ secrets.DOCS_CI_RO_PAT }} - name: Check out installation repository + if: steps.preview.outputs.targeted != 'true' uses: actions/checkout@v4 with: repository: validmind/installation @@ -51,17 +137,8 @@ jobs: site/installation sparse-checkout-cone-mode: true - - name: Check out release-notes repository - uses: actions/checkout@v4 - with: - repository: validmind/release-notes - path: site/_source/release-notes - token: ${{ secrets.DOCS_CI_RO_PAT }} - sparse-checkout: | - releases - sparse-checkout-cone-mode: true - - name: Check out backend repository + if: steps.preview.outputs.targeted != 'true' uses: actions/checkout@v4 with: repository: validmind/backend @@ -72,6 +149,7 @@ jobs: sparse-checkout-cone-mode: true - name: Set up uv + if: steps.preview.outputs.targeted != 'true' uses: astral-sh/setup-uv@v5 - name: Verify copyright headers @@ -84,7 +162,13 @@ jobs: with: version: pre-release + - 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.preview.outputs.targeted != 'true' run: | cd site/_source/validmind-library make install && make quarto-docs @@ -94,10 +178,12 @@ jobs: rsync -av --exclude '_build' --exclude 'templates' _source/validmind-library/docs/ validmind/ - name: Generate template schema docs + 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.preview.outputs.targeted != 'true' run: cp -r site/_source/installation/site/installation site/installation - name: Populate release notes @@ -105,15 +191,91 @@ jobs: cp -r site/_source/release-notes/releases site rm -f site/releases/backend-releases.qmd site/releases/cmvm-releases.qmd - - name: Render demo docs site + # The staging HTML lets Quarto resolve descriptions for listings without + # re-rendering the other ~1,000 pages. Only HTML and the two global indexes + # are downloaded; the final preview is cloned server-side during deployment. + - name: Configure AWS credentials + 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.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 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.preview.outputs.mode == 'release' run: | - cd site - quarto render --profile development 2>&1 | tee render_errors.log || { + 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; } - make generate-sitemap + done < ../.release-preview-targets + + for target in releases/*.qmd; do + [[ "$(basename "$target")" == _* ]] && continue + quarto render --profile development "$target" 2>&1 | tee -a render_errors.log || { + echo "Quarto render failed immediately"; + cat render_errors.log; + exit 1; + } + done + + 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 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.preview.outputs.targeted != 'true' + run: | + cd site + quarto render --profile development 2>&1 | tee render_errors.log || { + echo "Quarto render failed immediately"; + cat render_errors.log; + exit 1; + } + make generate-sitemap - name: Add robots.txt for PR preview run: cp site/environments/robots-staging.txt site/_site/robots.txt @@ -128,12 +290,27 @@ jobs: echo "No warnings or errors detected during Quarto render" fi - # Demo bucket is in us-east-1 - - name: Configure AWS credentials - 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: Deploy PR preview - run: aws s3 sync site/_site s3://validmind-docs-staging/site/pr_previews/${{ github.head_ref }} --delete --exclude "notebooks/EXECUTED/*" --cache-control "no-cache, max-age=0, must-revalidate" && aws cloudfront create-invalidation --distribution-id ESWVTZYFL873V --paths "/*" --no-cli-pager + run: | + preview_path="s3://validmind-docs-staging/site/pr_previews/${{ github.head_ref }}" + 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 + 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/*" \ + --cache-control "no-cache, max-age=0, must-revalidate" + fi + aws cloudfront create-invalidation --distribution-id ESWVTZYFL873V --paths "/*" --no-cli-pager - name: Post comment with preview URL uses: actions/github-script@v6 @@ -185,11 +362,13 @@ jobs: console.log(`Dispatched Lighthouse check for PR #${context.issue.number}`); - name: Install pandoc + 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.preview.outputs.targeted != 'true' run: | set -euo pipefail python3 site/scripts/generate_chatbot_product_map.py @@ -222,13 +401,16 @@ jobs: echo "Auto-committed refreshed site/llm/chatbot-product-map.md." - name: Test chatbot product map generator + 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.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.preview.outputs.targeted != 'true' run: | test -f site/llm/_llm-output/chatbot-product-map.md test -f site/llm/_llm-output/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 52f3c75e8c..c31b2cdeab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ This page explains: If you are an AI agent embedded in ValidMind, your capabilities are documented here: -**[Chatbot capabilities](https://docs.validmind.ai/guide/chatbot-capabilities.html)** +**[ValidMind in-app assistant](https://docs.validmind.ai/guide/chatbot-capabilities.html)** This page describes what the assistant can and cannot do, including context-aware features and current limitations. @@ -48,3 +48,52 @@ Documentation is written in Quarto Markdown (`.qmd`). Key conventions: - Variables use `{{< var name >}}` syntax (defined in `_variables.yml`) - Cross-references use relative paths ending in `.qmd` - Images are stored alongside their `.qmd` files + +## Documentation coverage reviews + +When validating documentation coverage for a Shortcut story or engineering change: + +- Read the full story and inspect the final implementation pull requests. Treat the implemented behavior as authoritative when it differs from the story description, while verifying merge and release state before calling it shipped. +- Compare that behavior with documentation on current `origin/main` and classify coverage as Covered, Partial, Outdated, Missing, or Source Gap. +- Search for every consumer of edited Quarto includes and validate all affected formats, including both HTML guides and RevealJS training where applicable. +- Render affected pages one at a time. Use `skills/validmind-docs-coverage/scripts/render-pages.sh` for multi-page validation. +- Add direct links to each changed page in the pull request after the ready-for-review `validate` job deploys the preview. +- Verify current review, validation, and merge state immediately before describing a pull request in Shortcut or a release tracker. Do not update tracker task state unless explicitly requested. + +For the complete Shortcut-to-documentation workflow, use [ValidMind documentation coverage](skills/validmind-docs-coverage/SKILL.md). + +## Pull requests and release notes + +Documentation pull requests must follow the repository's release-note policy: + +- Internal workflow, tooling, or maintenance changes use the `internal` label. +- External changes use an appropriate release-note label and include content in the pull request's release-notes section. + +The required `validate` check is the pull-request feedback gate. For ordinary content changes, it renders only safely targetable changed pages and assets and builds the preview on top of the validated staging site. It falls back to a complete preview render for changes that can have global or ambiguous effects, including Quarto configuration or metadata, generated content, deletions, and renames. + +## Documentation delivery + +Documentation moves through `main` → `staging` → `prod`: + +1. Pull requests into `main` receive preview validation and normal review. +2. After merge, the staging workflow renders the complete staging site and the prospective production site in parallel. +3. The prospective production build runs the complete production-profile validation and uploads an immutable artifact keyed to the exact Git tree that a `staging` → `prod` merge will create. +4. The production workflow deploys only that exact-tree artifact from a successful staging workflow run. If the artifact is missing, expired, or came from another workflow, production deployment must fail before loading AWS credentials or modifying production. + +Release-note content is sourced from `validmind/release-notes`, so merging a +release-notes pull request does not create or merge a documentation pull request. +Instead, the release-notes repository dispatches the existing **Merge main into +staging** workflow. Its successful completion triggers the staging deployment, +which builds the promoted documentation tree with the latest release-notes +`main` revision. + +The merge-queue `validate` bridge does not render the site again. It records that the pull-request revision passed preview validation; the complete production safety boundary is the post-merge staging artifact. + +## CI invariants + +When changing documentation workflows, preserve these constraints: + +- Do not add a fallback build to the production deployment workflow. Missing validated artifacts must fail closed. +- Keep full Git history available when preparing the prospective production tree. A shallow checkout cannot establish the shared `staging`/`prod` history and causes Git to reject the merge as unrelated histories. +- Keep targeted preview runs cancelable so a newer commit supersedes obsolete work. +- Treat preview rendering and production validation as different responsibilities: previews provide fast author feedback; only the complete post-merge production-profile build can authorize a production artifact. diff --git a/site/_quarto.yml b/site/_quarto.yml index 9cdad331c7..7a9d1a8903 100644 --- a/site/_quarto.yml +++ b/site/_quarto.yml @@ -205,6 +205,7 @@ website: