diff --git a/.github/actions/cleanup-site/action.yml b/.github/actions/cleanup-site/action.yml new file mode 100644 index 00000000..ad93e5c3 --- /dev/null +++ b/.github/actions/cleanup-site/action.yml @@ -0,0 +1,130 @@ +# Cleanup site state: remove pr-preview directories for closed PRs. +# Keeps only directories for currently open PRs so saved state matches reality. +# This action keeps the existing pages only for the actually opened PRs to avoid restoring +# the pages of closed PRs. + +name: 'Cleanup site state' +description: 'Remove PR preview directories for closed PRs; keep only open PRs in site state' + +inputs: + site_dir: + description: 'Path to the site directory (e.g. _site) containing pr-preview subdirs' + required: true + +runs: + using: 'composite' + steps: + - name: Collect list of open PRs + id: open-prs + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + + # Build JSON array of open PR numbers for use in next step. + if ! OPEN_PRS_JSON=$(gh pr list --state open --json number -q '.[].number' --limit 100 | jq -R -s -c '[split("\n")[] | select(length > 0) | tonumber]'); then + echo "Error: Failed to list open PRs" >&2 + exit 1 + fi + + # Validate the output is valid JSON array + if ! echo "$OPEN_PRS_JSON" | jq -e 'type == "array"' >/dev/null 2>&1; then + echo "Error: Invalid JSON output from PR list" >&2 + exit 1 + fi + + echo "open_prs_json=$OPEN_PRS_JSON" >> "$GITHUB_OUTPUT" + echo "Found $(echo "$OPEN_PRS_JSON" | jq 'length') open PR(s)" + + - name: Remove closed PR preview directories + shell: bash + env: + SITE_DIR: ${{ inputs.site_dir }} + OPEN_PRS_JSON: ${{ steps.open-prs.outputs.open_prs_json }} + run: | + set -e + + # Validate site_dir input + if [ -z "$SITE_DIR" ]; then + echo "Error: site_dir is empty" >&2 + exit 1 + fi + + # Reject paths with path traversal, absolute paths, or control characters + if [[ "$SITE_DIR" == *".."* ]] || \ + [[ "$SITE_DIR" == /* ]] || \ + [[ "$SITE_DIR" == *$'\n'* ]] || \ + [[ "$SITE_DIR" == *$'\r'* ]] || \ + [[ "$SITE_DIR" == *$'\t'* ]]; then + echo "Error: Invalid site_dir (path traversal or absolute path)" >&2 + exit 1 + fi + + # Get absolute path of workspace root for boundary checking + WORKSPACE_ROOT=$(cd "$GITHUB_WORKSPACE" && pwd) + + # Resolve and validate site_dir path + if [ ! -d "$SITE_DIR" ]; then + echo "Info: site_dir does not exist: $SITE_DIR (first run or no previous state)" >&2 + exit 0 + fi + + RESOLVED_SITE_DIR=$(cd "$SITE_DIR" && pwd) + + # Ensure resolved path is within workspace + if [[ "$RESOLVED_SITE_DIR" != "$WORKSPACE_ROOT"* ]]; then + echo "Error: site_dir resolves outside workspace" >&2 + exit 1 + fi + + PR_PREVIEW="${RESOLVED_SITE_DIR}/pr-preview" + + # Validate PR_PREVIEW path is still within workspace + if [[ "$PR_PREVIEW" != "$WORKSPACE_ROOT"* ]]; then + echo "Error: PR_PREVIEW path escapes workspace" >&2 + exit 1 + fi + + # If there is no previews, just exit + if [ ! -d "$PR_PREVIEW" ]; then + exit 0 + fi + + # Process each pr-* directory + for dir in "$PR_PREVIEW"/pr-*; do + RESOLVED_DIR=$(cd "$dir" && pwd) + + # Skip if glob didn't match anything, files, symlinks, etc. + if [ ! -e "$dir" ] || [ ! -d "$dir" ] || [[ "$RESOLVED_DIR" != "$WORKSPACE_ROOT"* ]] || [[ "$RESOLVED_DIR" != "$PR_PREVIEW"* ]]; then + continue + fi + name=$(basename "$dir") + + # Validate directory name matches expected pattern (pr- followed by digits only) + if [[ "$name" =~ ^pr-([0-9]+)$ ]]; then + pr_num="${BASH_REMATCH[1]}" + + # Validate pr_num is numeric (extra safety) + if ! [[ "$pr_num" =~ ^[0-9]+$ ]]; then + echo "Warning: Invalid PR number extracted: $pr_num" >&2 + continue + fi + + # Validate OPEN_PRS_JSON is valid before using it + if ! echo "$OPEN_PRS_JSON" | jq -e 'type == "array"' >/dev/null 2>&1; then + echo "Error: Invalid OPEN_PRS_JSON format" >&2 + exit 1 + fi + + # Check if PR is still open + if ! echo "$OPEN_PRS_JSON" | jq -e --argjson n "$pr_num" 'index($n) != null' >/dev/null 2>&1; then + rm -rf "$dir" + fi + fi + done + + # Clean up empty pr-preview directory + if [ -d "$PR_PREVIEW" ] && [ -z "$(ls -A "$PR_PREVIEW" 2>/dev/null)" ]; then + rm -rf "$PR_PREVIEW" + fi diff --git a/.github/actions/generate-docs-comment/action.yml b/.github/actions/generate-docs-comment/action.yml index d66d037b..41b7b770 100644 --- a/.github/actions/generate-docs-comment/action.yml +++ b/.github/actions/generate-docs-comment/action.yml @@ -40,58 +40,57 @@ runs: build_timestamp="$BUILD_TIMESTAMP" commit_sha="$COMMIT_SHA" - cat << 'EOF' > comment.md - ## Documentation Preview - - The documentation has been built successfully and is available for preview. - - | Build Info | Value | - |------------|-------| - | **Generated at** | `$TIMESTAMP_PLACEHOLDER` | - | **Commit** | `$COMMIT_PLACEHOLDER` | - - ### Preview Links - - | Page | Preview Link | - |------|--------------| - | **Index (Home)** | [View Preview]($preview_url_placeholder/index.html) | - EOF - - # Replace placeholders with actual values - sed -i "s|\$preview_url_placeholder|$preview_url|g" comment.md - sed -i "s|\$TIMESTAMP_PLACEHOLDER|$build_timestamp|g" comment.md - sed -i "s|\$COMMIT_PLACEHOLDER|$commit_sha|g" comment.md + # Initialize comment.md file + echo "## Documentation Preview" > comment.md + echo "" >> comment.md + echo "The documentation has been built successfully. You can view the preview here: [preview]($preview_url/index.html)" >> comment.md + echo "" >> comment.md + echo "**Generated at**: \`$build_timestamp\` with commit \`$commit_sha\`." >> comment.md # Add links to changed files - if [ "$changed_docs" != "[]" ] && [ -n "$changed_docs" ]; then + # Parse JSON and get count of items + doc_count=$(echo "$changed_docs" | jq -r 'if type == "array" then length else 0 end' 2>/dev/null || echo "0") + + # Always show details section to ensure GitHub renders it + # Check if we have any files to show + has_files=false + if [ "$doc_count" != "0" ] && [ "$doc_count" != "null" ] && [ -n "$doc_count" ]; then + # Verify it's actually a number and > 0 + if [ "$doc_count" -gt 0 ] 2>/dev/null; then + has_files=true + fi + fi + + echo "" >> comment.md + echo "
" >> comment.md + echo "Expand to view changed pages" >> comment.md + echo "" >> comment.md + + if [ "$has_files" = "true" ]; then echo "" >> comment.md - echo "### Changed Pages" >> comment.md echo "" >> comment.md - echo "| Source File | Preview Link |" >> comment.md - echo "|-------------|--------------|" >> comment.md - - echo "$changed_docs" | jq -r '.[]' | while read -r file; do - if [ -n "$file" ]; then - # Convert .rst/.md path to .html path - # Remove 'docs/' prefix and change extension + echo "| File | Preview |" >> comment.md + echo "| ---- | ------- |" >> comment.md + while IFS= read -r file; do + if [ -n "$file" ] && [ "$file" != "null" ]; then html_path=$(echo "$file" | sed 's|^docs/||' | sed 's|\.rst$|.html|' | sed 's|\.md$|.html|') - # Handle index files in subdirectories if [[ "$html_path" == */index.html ]]; then html_path="${html_path}" fi - filename=$(basename "$file") echo "| \`$file\` | [View]($preview_url/$html_path) |" >> comment.md fi - done + done < <(echo "$changed_docs" | jq -r '.[]' 2>/dev/null) + else + echo "No documentation files were changed in this PR." >> comment.md fi - cat << 'EOF' >> comment.md - - --- + echo "" >> comment.md + echo "
" >> comment.md - > **Note:** The preview will be available after GitHub Pages deployment completes (usually 1-2 minutes). - > If you see stale content, hard-refresh your browser (Ctrl+Shift+R) or wait a moment for GitHub Pages to update. - EOF + echo "" >> comment.md + echo "---" >> comment.md + echo "" >> comment.md + echo "> **Note:** The preview will be available after GitHub Pages deployment completes (usually 1-2 minutes). Ensure that **generated at** timestamp is up to date. If the Pull Request is stale for more than 30 days, the preview may be deleted. In this case, please update the Pull Request, or re-run the Build Documentation workflow to generate a new preview." >> comment.md echo "Comment content:" cat comment.md diff --git a/.github/actions/get-changed-docs/action.yml b/.github/actions/get-changed-docs/action.yml index c638d91a..b68bce9b 100644 --- a/.github/actions/get-changed-docs/action.yml +++ b/.github/actions/get-changed-docs/action.yml @@ -40,7 +40,7 @@ runs: HEAD_SHA: ${{ inputs.head_sha }} run: | # Get list of changed .rst and .md files in docs/ - changed_files=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'docs/*.rst' 'docs/*.md' 'docs/**/*.rst' 'docs/**/*.md' | head -20) + changed_files=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'docs/*.rst' 'docs/*.md' 'docs/**/*.rst' 'docs/**/*.md' 'samples/**/*.rst' | head -20) echo "Changed documentation files:" echo "$changed_files" diff --git a/.github/workflows/docbuild.yml b/.github/workflows/docbuild.yml index 3a9dee25..275afa4b 100644 --- a/.github/workflows/docbuild.yml +++ b/.github/workflows/docbuild.yml @@ -6,7 +6,8 @@ on: - main paths: - 'docs/**' - - '.github/workflows/docbuild.yml' + - '.github/workflows/doc*' + - 'samples/**/*.rst' permissions: contents: read diff --git a/.github/workflows/docpreview.yml b/.github/workflows/docpreview.yml index 61095d35..eee14caf 100644 --- a/.github/workflows/docpreview.yml +++ b/.github/workflows/docpreview.yml @@ -30,40 +30,51 @@ jobs: - name: Checkout repository uses: actions/checkout@1e31de5234b9f8995739874a8ce0492dc87873e2 # v4 - - name: Download site state from cleanup - id: download-cleanup - uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 # v6 - with: - workflow: docremove.yml - name: site-state - path: _existing_pages - workflow_conclusion: success - search_artifacts: true - if_no_artifact_found: warn - continue-on-error: true - - name: Download site state from deploy id: download-deploy - if: steps.download-cleanup.outcome == 'failure' uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 # v6 with: workflow: docpreview.yml name: site-state - path: _existing_pages + path: ${{ runner.temp }}/existing_pages workflow_conclusion: success search_artifacts: true if_no_artifact_found: warn - continue-on-error: true + continue-on-error: false - name: Remove symlinks from downloaded artifacts run: | - find _existing_pages -type l -delete 2>/dev/null || true + if [ -d "${{ runner.temp }}/existing_pages" ]; then + find "${{ runner.temp }}/existing_pages" -type l -delete 2>/dev/null || true + rm -rf _existing_pages + mkdir -p _existing_pages + cp -a "${{ runner.temp }}/existing_pages/." _existing_pages/ || true + fi - name: Download build artifacts uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 # v6 with: workflow: docbuild.yml run_id: ${{ github.event.workflow_run.id }} + path: ${{ runner.temp }} + + - name: Move doc-preview from temp + run: | + if [ -d "doc-preview" ]; then + rm -rf doc-preview + fi + if [ -d "${{ runner.temp }}/doc-preview" ]; then + mv "${{ runner.temp }}/doc-preview" ./doc-preview + else + echo "Expected doc-preview directory not found in runner.temp" >&2 + exit 1 + fi + + - name: Cleanup site state + id: download-site-state + uses: ./.github/actions/cleanup-site + with: + site_dir: _existing_pages - name: Extract version and unzip docs working-directory: doc-preview @@ -123,7 +134,7 @@ jobs: with: name: site-state path: _site - retention-days: 14 # 2 weeks + retention-days: 30 # 1 month, recall Build Documentation workflow to re-generate preview overwrite: true - name: Find existing comment diff --git a/.github/workflows/docremove.yml b/.github/workflows/docremove.yml deleted file mode 100644 index 3ae9db7f..00000000 --- a/.github/workflows/docremove.yml +++ /dev/null @@ -1,123 +0,0 @@ -name: Cleanup PR preview - -on: - workflow_run: - workflows: ["PR closed"] - types: - - completed - -permissions: - contents: read - pages: write - id-token: write - actions: write - -# Use same concurrency group as deploy to avoid conflicts -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - cleanup: - runs-on: ubuntu-24.04 - if: ${{ github.event.workflow_run.conclusion == 'success' }} - environment: - name: github-pages - - steps: - - name: Download PR info artifact - uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 # v6 - with: - workflow: post_pr.yml - run_id: ${{ github.event.workflow_run.id }} - name: pr-closed-info - - - name: Read PR info - id: pr-info - run: | - PR_NUMBER=$(cat pr_number.txt | tr -cd '0-9') - echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT - echo "PR number: $PR_NUMBER" - - - name: Download site state from deploy - id: download-deploy - uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 # v6 - with: - workflow: docpreview.yml - name: site-state - path: _site - workflow_conclusion: success - search_artifacts: true - if_no_artifact_found: warn - continue-on-error: true - - - name: Download site state from cleanup - id: download-cleanup - if: steps.download-deploy.outcome == 'failure' - uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 # v6 - with: - workflow: docremove.yml - name: site-state - path: _site - workflow_conclusion: success - search_artifacts: true - if_no_artifact_found: warn - continue-on-error: true - - - name: Verify site state exists - run: | - if [ ! -d "_site" ] || [ -z "$(ls -A _site 2>/dev/null)" ]; then - echo "Error: Could not download site-state artifact from any workflow" - echo "This may happen if no documentation has been deployed yet" - exit 1 - fi - # Remove symlinks from downloaded artifacts (security) - find _site -type l -delete - echo "Site state downloaded successfully" - ls -la _site/ - - - name: Remove PR preview directory - id: cleanup - run: | - pr_number="${{ steps.pr-info.outputs.pr_number }}" - preview_dir="_site/pr-preview/pr-${pr_number}" - - if [ -d "$preview_dir" ]; then - echo "Removing preview directory: $preview_dir" - rm -rf "$preview_dir" - - # Check if pr-preview directory is empty and remove if so - if [ -d "_site/pr-preview" ] && [ -z "$(ls -A _site/pr-preview)" ]; then - rm -rf "_site/pr-preview" - fi - - echo "cleaned_up=true" >> $GITHUB_OUTPUT - echo "Preview cleaned up successfully" - else - echo "No preview directory found for PR #${pr_number}" - echo "cleaned_up=false" >> $GITHUB_OUTPUT - fi - - - name: Setup Pages - if: steps.cleanup.outputs.cleaned_up == 'true' - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 - - - name: Upload Pages artifact - if: steps.cleanup.outputs.cleaned_up == 'true' - uses: actions/upload-pages-artifact@0252fc4ba7626f0298f0cf00902a25c6afc77fa8 # v3 - with: - path: '_site' - - - name: Deploy to GitHub Pages - if: steps.cleanup.outputs.cleaned_up == 'true' - uses: actions/deploy-pages@f33f41b675f0ab2dc5a6863c9a170fe83af3571e # v4 - - # Save updated site state - - name: Save site state artifact - if: steps.cleanup.outputs.cleaned_up == 'true' - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4 - with: - name: site-state - path: _site - retention-days: 14 # 2 weeks - overwrite: true diff --git a/.github/workflows/on_pr.yml b/.github/workflows/on_pr.yml index 4f65e3eb..1de5a682 100644 --- a/.github/workflows/on_pr.yml +++ b/.github/workflows/on_pr.yml @@ -7,6 +7,13 @@ on: - 'LICENSE' - 'CODEOWNERS' - '.gitignore' + - '.github/workflows/doc*.yml' + - '.github/actions/generate-docs-comment/action.yml' + - '.github/actions/set-deploy-info/action.yml' + - '.github/actions/cleanup-site/action.yml' + - '.github/actions/deploy-docs-preview/action.yml' + - '.github/actions/generate-docs-comment/action.yml' + - '.github/actions/get-changed-docs/action.yml' branches: - main diff --git a/.github/workflows/post_pr.yml b/.github/workflows/post_pr.yml deleted file mode 100644 index 126e9ca4..00000000 --- a/.github/workflows/post_pr.yml +++ /dev/null @@ -1,26 +0,0 @@ - -name: PR closed - -on: - pull_request: - types: [closed] - -permissions: - contents: read - -jobs: - save_pr_info: - runs-on: ubuntu-24.04 - - steps: - - name: Save PR info for cleanup - run: | - echo "${{ github.event.pull_request.number }}" > pr_number.txt - - - name: Upload PR info artifact - uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 # v4 - with: - name: pr-closed-info - path: | - pr_number.txt - retention-days: 1 diff --git a/docs/requirements-doc.txt b/docs/requirements-doc.txt index 8c2b371d..3d893a04 100644 --- a/docs/requirements-doc.txt +++ b/docs/requirements-doc.txt @@ -26,7 +26,7 @@ sphinxcontrib-qthelp==1.0.3 sphinxcontrib-serializinghtml==1.1.5 # Jinja2 compatibility for Sphinx 4.x -Jinja2<3.1 +Jinja2>=3.1.6 MarkupSafe>=2.0.0,<2.2.0 # Other dependencies