diff --git a/.github/actions/docs-analysis/INTEGRATION.md b/.github/actions/docs-analysis/INTEGRATION.md new file mode 100644 index 0000000000000..9e5cf0d070324 --- /dev/null +++ b/.github/actions/docs-analysis/INTEGRATION.md @@ -0,0 +1,140 @@ +# Integrating with docs-preview-link Workflow + +This guide shows how to integrate the `docs-analysis` composite action with the existing `docs-preview-link.yml` workflow, eliminating duplication and consolidating documentation processing. + +## Current State + +The docs-preview-link.yml workflow currently embeds document analysis functionality directly in the workflow steps, which leads to: +- Code duplication across workflows +- Harder maintenance when metrics need to be updated +- Inconsistent reporting between workflows + +## Integration Strategy + +We can refactor the `docs-preview-link.yml` workflow to use our new composite action, bringing these benefits: +- Single source of truth for document analysis +- Consistent metrics across all documentation workflows +- Easier maintenance and feature additions +- Improved security and error handling + +## Example Integration + +Here's how to replace the verify-docs-changes job in the docs-preview-link.yml workflow with our composite action: + +```yaml +verify-docs-changes: + needs: [validate-workflow, delay-start] + runs-on: ubuntu-latest + timeout-minutes: 3 # Reduced timeout for verification step + if: | + always() && + (needs.validate-workflow.result == 'success' || needs.validate-workflow.result == 'skipped') + permissions: + contents: read + pull-requests: read + checks: write # For creating check runs + statuses: write # For creating commit statuses + if: | + always() && ( + (github.event_name == 'pull_request_target' && + (github.event.pull_request.draft == false || contains(github.event.pull_request.labels.*.name, 'run-checks-on-draft'))) || + (github.event_name == 'workflow_dispatch') || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && + (contains(github.event.comment.body, '/docs-preview') || contains(github.event.comment.body, '/docs-help'))) + ) + outputs: + docs_changed: ${{ steps.docs-analysis.outputs.docs-changed }} + pr_number: ${{ steps.pr_info.outputs.pr_number }} + branch_name: ${{ steps.pr_info.outputs.branch_name }} + repo_owner: ${{ steps.pr_info.outputs.repo_owner }} + is_fork: ${{ steps.pr_info.outputs.is_fork }} + is_comment: ${{ steps.pr_info.outputs.is_comment }} + is_manual: ${{ steps.pr_info.outputs.is_manual }} + skip: ${{ steps.pr_info.outputs.skip }} + execution_start_time: ${{ steps.timing.outputs.start_time }} + has_non_docs_changes: ${{ steps.docs-analysis.outputs.has-non-docs-changes }} + words_added: ${{ steps.docs-analysis.outputs.words-added }} + words_removed: ${{ steps.docs-analysis.outputs.words-removed }} + docs_files_count: ${{ steps.docs-analysis.outputs.docs-files-count }} + images_added: ${{ steps.docs-analysis.outputs.images-added }} + manifest_changed: ${{ steps.docs-analysis.outputs.manifest-changed }} + format_only: ${{ steps.docs-analysis.outputs.format-only }} + steps: + # Start timing the execution for performance tracking + - name: Capture start time + id: timing + run: | + echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT + echo "::notice::Starting docs preview workflow at $(date)" + + # Apply security hardening to the runner + - name: Harden Runner + uses: step-security/harden-runner@latest + with: + egress-policy: audit + + - name: Create verification check run + id: create_check + uses: actions/github-script@latest + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + // [existing script code...] + + - name: Get PR info + id: pr_info + run: | + # [existing script code to get PR number, branch, etc.] + + # Only check out the DEFAULT branch (not the PR code) to verify changes safely + - name: Check out base repository code + if: steps.pr_info.outputs.skip != 'true' + uses: actions/checkout@latest + with: + ref: main # Always use the main branch + fetch-depth: 5 # Reduce checkout depth for faster runs + sparse-checkout: | + ${{ env.DOCS_PRIMARY_PATH }} + *.md + README.md + sparse-checkout-cone-mode: false + + # NEW: Use our composite action instead of duplicate logic + - name: Analyze documentation changes + id: docs-analysis + if: steps.pr_info.outputs.skip != 'true' + uses: ./.github/actions/docs-analysis + with: + docs-path: ${{ env.DOCS_PRIMARY_PATH }} + pr-ref: ${{ steps.pr_info.outputs.branch_name }} + base-ref: 'main' + significant-words-threshold: ${{ env.SIGNIFICANT_WORDS_THRESHOLD }} + throttle-large-repos: 'true' + debug-mode: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug == 'true' || 'false' }} + + # Remaining steps can use the outputs from docs-analysis + - name: Update verification status + if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && steps.pr_info.outputs.skip != 'true') + uses: actions/github-script@latest + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + // [script modified to use step.docs-analysis outputs] +``` + +## Benefits of Integration + +1. **Reduced Duplication**: The core document analysis logic is maintained in one place +2. **Consistent Features**: All documentation workflows get the same analysis capabilities +3. **Better Versioning**: Can pin to specific versions of the docs-analysis action +4. **Cleaner Workflow Files**: Simplified workflow YAML with better separation of concerns +5. **Improved Maintenance**: Changes to analysis logic only need to be made in one place +6. **Common Security Model**: Same input validation and security practices across workflows + +## Implementation Plan + +1. Create a small PR with the composite action (completed) +2. Test the action in isolation on sample PRs +3. Create a new PR that refactors docs-preview-link.yml to use the composite action +4. Refactor any other documentation workflows to use the same action +5. Establish a process for maintaining the shared action \ No newline at end of file diff --git a/.github/actions/docs-analysis/README.md b/.github/actions/docs-analysis/README.md new file mode 100644 index 0000000000000..a070d3924334e --- /dev/null +++ b/.github/actions/docs-analysis/README.md @@ -0,0 +1,252 @@ +# Docs Analysis Action + +A composite GitHub Action to analyze documentation changes in pull requests and provide useful metrics and insights. + +## Features + +- Detects documentation files changed in a PR +- Calculates metrics (files changed, words added/removed) +- Tracks image modifications with detailed reporting +- Analyzes document structure (headings, titles) +- Identifies the most significantly changed files +- Integrates with other doc workflows (weekly checks, PR previews) +- Provides standardized outputs that can be used by any workflow + +## Usage + +This action analyzes documentation changes to help provide better context and metrics for documentation PRs. +It only runs on PRs that modify files in the docs directory or markdown files elsewhere in the repo. + +### Basic Example + +```yaml +- name: Analyze Documentation Changes + uses: ./.github/actions/docs-analysis + id: docs-analysis + with: + docs-path: 'docs/' + pr-ref: ${{ github.event.pull_request.head.ref }} + base-ref: 'main' +``` + +### Integration with tj-actions/changed-files (Recommended) + +For optimal performance and reliability, we recommend using with `tj-actions/changed-files`: + +```yaml +- uses: tj-actions/changed-files@v45 + id: changed-files + with: + files: | + docs/** + **.md + separator: "," + +- name: Analyze Documentation Changes + id: docs-analysis + uses: ./.github/actions/docs-analysis + with: + docs-path: 'docs/' + changed-files: ${{ steps.changed-files.outputs.all_changed_files }} + files-pattern: 'docs/**|**.md' +``` + +### Complete Example with Conditionals + +```yaml +jobs: + check-docs-changes: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - uses: tj-actions/changed-files@v45 + id: changed-files + with: + files: | + docs/** + **.md + separator: "," + + - name: Analyze Documentation Changes + uses: ./.github/actions/docs-analysis + id: docs-analysis + with: + docs-path: 'docs/' + changed-files: ${{ steps.changed-files.outputs.all_changed_files }} + significant-words-threshold: '100' + skip-if-no-docs: 'true' + debug-mode: 'false' + + - name: Create Preview Comment + if: steps.docs-analysis.outputs.docs-changed == 'true' + run: | + echo "Found ${{ steps.docs-analysis.outputs.docs-files-count }} changed docs files" + echo "Words: +${{ steps.docs-analysis.outputs.words-added }}/-${{ steps.docs-analysis.outputs.words-removed }}" + + if [[ "${{ steps.docs-analysis.outputs.images-total }}" != "0" ]]; then + echo "Images changed: ${{ steps.docs-analysis.outputs.images-total }}" + fi + + if [[ "${{ steps.docs-analysis.outputs.significant-change }}" == "true" ]]; then + echo "This is a significant docs change!" + fi +``` + +## Inputs + +| Name | Description | Required | Default | +|------|-------------|----------|---------| +| `docs-path` | Path to the documentation directory | No | `docs/` | +| `files-pattern` | Glob pattern(s) for documentation files (use vertical bar \| to separate multiple patterns) | No | `**.md\|docs/**` | +| `changed-files` | Comma-separated list of changed files (from tj-actions/changed-files) | No | `` | +| `pr-ref` | PR reference to analyze | No | `github.event.pull_request.head.ref` | +| `base-ref` | Base reference to compare against | No | `main` | +| `files-changed` | Comma-separated list of files changed (legacy input, use `changed-files` instead) | No | `` | +| `max-scan-files` | Maximum number of files to scan | No | `100` | +| `max-files-to-analyze` | Maximum files to analyze in detail (for performance) | No | `20` | +| `throttle-large-repos` | Enable throttling for large repositories | No | `true` | +| `significant-words-threshold` | Threshold for significant text changes | No | `100` | +| `skip-if-no-docs` | Whether to skip if no docs files are changed | No | `true` | +| `debug-mode` | Enable verbose debugging output | No | `false` | +| `use-changed-files-action` | Whether to use tj-actions/changed-files instead of git commands | No | `false` | + +## Outputs + +| Name | Description | +|------|-------------| +| `docs-changed` | Whether documentation files were changed (`true`/`false`) | +| `docs-files-count` | Number of documentation files changed | +| `words-added` | Number of words added to documentation | +| `words-removed` | Number of words removed from documentation | +| `images-added` | Number of images added | +| `images-modified` | Number of images modified | +| `images-deleted` | Number of images deleted | +| `images-total` | Total number of images changed | +| `image-names` | Comma-separated list of changed image files | +| `manifest-changed` | Whether manifest.json was changed (`true`/`false`) | +| `format-only` | Whether changes are formatting-only (`true`/`false`) | +| `significant-change` | Whether changes are significant (`true`/`false`) | +| `has-non-docs-changes` | Whether PR contains non-docs changes (`true`/`false`) | +| `most-changed-file` | Path to the most changed file | +| `most-changed-url-path` | URL path for the most changed file | +| `most-significant-image` | Path to the most significant image | +| `doc-structure` | JSON structure of document heading counts | +| `execution-time` | Execution time in seconds | +| `cache-key` | Cache key for this analysis run | + +## Security Features + +- Stronger input validation with whitelist approach for branch references +- Enhanced path sanitization with traversal detection +- Secure command execution (no eval) in git retry operations +- Error tracing with line numbers for better debugging +- Cross-platform compatibility with fallbacks +- Repository size detection with adaptive throttling +- Python integration for safer JSON handling (with bash fallbacks) +- Performance monitoring with execution metrics + +## Performance Optimization + +- Configurable document scan limits +- Intelligent throttling for large repositories +- Git performance tuning +- Execution time tracking +- Content-based caching +- Debug mode for troubleshooting + +## Examples + +### Analyzing Documentation Changes for a PR + +```yaml +- name: Analyze Documentation Changes + uses: ./.github/actions/docs-analysis + id: docs-analysis + with: + docs-path: 'docs/' +``` + +### Analyzing Non-Git Files + +```yaml +- name: Analyze Documentation Files + uses: ./.github/actions/docs-analysis + id: docs-analysis + with: + files-changed: 'docs/file1.md,docs/file2.md,README.md' + docs-path: 'docs/' +``` + +### Debug Mode for Troubleshooting + +```yaml +- name: Analyze Documentation with Debug Output + uses: ./.github/actions/docs-analysis + id: docs-analysis + with: + docs-path: 'docs/' + debug-mode: 'true' +``` + +## Unified Documentation Workflows + +This action is designed to work seamlessly with Coder's other documentation-related workflows: + +### How to Use with docs-ci.yaml + +The `docs-ci.yaml` workflow uses this action to analyze documentation changes for linting and formatting: + +```yaml +# From .github/workflows/docs-ci.yaml +- uses: tj-actions/changed-files@v45 + id: changed-files + with: + files: | + docs/** + **.md + separator: "," + +- name: Analyze documentation changes + id: docs-analysis + uses: ./.github/actions/docs-analysis + with: + docs-path: "docs/" + changed-files: ${{ steps.changed-files.outputs.all_changed_files }} + files-pattern: "docs/**|**.md" +``` + +### How to Use with docs-preview-link.yml + +This action can be used in the `docs-preview-link.yml` workflow to analyze documentation changes for preview generation: + +```yaml +# Example integration with docs-preview-link.yml +- name: Analyze documentation changes + id: docs-analysis + uses: ./.github/actions/docs-analysis + with: + docs-path: "docs/" + pr-ref: ${{ steps.pr_info.outputs.branch_name }} + base-ref: 'main' +``` + +### How to Use with weekly-docs.yaml + +This action can be used to enhance the weekly documentation checks: + +```yaml +# Example integration with weekly-docs.yaml +- name: Analyze documentation structure + id: docs-analysis + uses: ./.github/actions/docs-analysis + with: + docs-path: "docs/" + files-pattern: "docs/**" + max-scan-files: "500" # Higher limit for full repo scan +``` + +By using this shared action across all documentation workflows, you ensure consistent analysis, metrics, and reporting for all documentation-related tasks. \ No newline at end of file diff --git a/.github/actions/docs-analysis/action.yml b/.github/actions/docs-analysis/action.yml new file mode 100644 index 0000000000000..f66ed005a114d --- /dev/null +++ b/.github/actions/docs-analysis/action.yml @@ -0,0 +1,875 @@ +name: 'Docs Analysis' +description: 'Analyzes documentation changes, extracts metrics, and provides contextual information' +author: 'Coder Team' + +# Define inputs for the action - all are optional with sane defaults +inputs: + docs-path: + description: 'Path to the documentation directory' + required: false + default: 'docs/' + files-pattern: + description: 'Glob pattern(s) for documentation files (use vertical bar | to separate multiple patterns)' + required: false + default: '**.md|docs/**' + changed-files: + description: 'Comma-separated list of changed files (from tj-actions/changed-files)' + required: false + default: '' + pr-ref: + description: 'PR reference to analyze (e.g., refs/pull/123/head)' + required: false + default: ${{ github.event.pull_request.head.ref }} + base-ref: + description: 'Base reference to compare against' + required: false + default: 'main' + max-scan-files: + description: 'Maximum number of files to scan' + required: false + default: '100' + max-files-to-analyze: + description: 'Maximum files to analyze in detail (for performance)' + required: false + default: '20' + throttle-large-repos: + description: 'Enable throttling for large repositories' + required: false + default: 'true' + significant-words-threshold: + description: 'Threshold for significant text changes' + required: false + default: '100' + skip-if-no-docs: + description: 'Whether to skip if no docs files are changed' + required: false + default: 'true' + debug-mode: + description: 'Enable verbose debugging output' + required: false + default: 'false' + use-changed-files-action: + description: 'Whether to use tj-actions/changed-files instead of git commands' + required: false + default: 'false' + +# Define outputs that this action will provide +outputs: + docs-changed: + description: 'Whether documentation files were changed' + value: ${{ steps.verify.outputs.docs_changed }} + docs-files-count: + description: 'Number of documentation files changed' + value: ${{ steps.verify.outputs.docs_files_count }} + words-added: + description: 'Number of words added to documentation' + value: ${{ steps.verify.outputs.words_added }} + words-removed: + description: 'Number of words removed from documentation' + value: ${{ steps.verify.outputs.words_removed }} + images-added: + description: 'Number of images added' + value: ${{ steps.verify.outputs.images_added }} + images-modified: + description: 'Number of images modified' + value: ${{ steps.verify.outputs.images_modified }} + images-deleted: + description: 'Number of images deleted' + value: ${{ steps.verify.outputs.images_deleted }} + images-total: + description: 'Total number of images changed' + value: ${{ steps.verify.outputs.images_total }} + image-names: + description: 'Comma-separated list of changed image files' + value: ${{ steps.verify.outputs.image_names }} + manifest-changed: + description: 'Whether manifest.json was changed' + value: ${{ steps.verify.outputs.manifest_changed }} + manifest-changed-files: + description: 'List of files referenced in manifest changes' + value: ${{ steps.verify.outputs.manifest_changed_files }} + format-only: + description: 'Whether changes are formatting-only' + value: ${{ steps.verify.outputs.format_only }} + significant-change: + description: 'Whether changes are significant' + value: ${{ steps.verify.outputs.significant_change }} + image-focused: + description: 'Whether changes are focused on images' + value: ${{ steps.verify.outputs.image_focused }} + has-non-docs-changes: + description: 'Whether PR contains non-docs changes' + value: ${{ steps.verify.outputs.has_non_docs_changes }} + changed-docs-files: + description: 'List of changed docs files' + value: ${{ steps.verify.outputs.changed_docs_files }} + docs-dir-files: + description: 'List of changed files in docs directory' + value: ${{ steps.verify.outputs.docs_dir_files }} + most-changed-file: + description: 'Path to the most changed file' + value: ${{ steps.find_changed_files.outputs.most_changed_file }} + most-changed-url-path: + description: 'URL path for the most changed file' + value: ${{ steps.find_changed_files.outputs.most_changed_url_path }} + most-significant-image: + description: 'Path to the most significant image' + value: ${{ steps.find_changed_files.outputs.most_significant_image }} + doc-structure: + description: 'JSON structure of document heading counts' + value: ${{ steps.analyze_structure.outputs.doc_structure }} + execution-time: + description: 'Execution time in seconds' + value: ${{ steps.execution_timing.outputs.duration }} + cache-key: + description: 'Cache key for this analysis run' + value: ${{ steps.cache.outputs.cache_key }} + +# This is a composite action that runs multiple steps +runs: + using: "composite" + steps: + # Start timing to measure execution performance + - name: Capture start time + id: timing + shell: bash + run: | + echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT + echo "Analysis starting at $(date)" + + # Validate inputs to prevent errors + - name: Validate inputs + shell: bash + run: | + # Validate docs-path exists + if [[ ! -d "${{ inputs.docs-path }}" ]]; then + echo "::warning::Documentation path '${{ inputs.docs-path }}' does not exist - some functions may not work correctly" + fi + + # Validate branch references with basic security checks for shell injection chars + if [[ "${{ inputs.pr-ref }}" == *";"* || "${{ inputs.pr-ref }}" == *"&"* || + "${{ inputs.pr-ref }}" == *"|"* || "${{ inputs.pr-ref }}" == *">"* || + "${{ inputs.pr-ref }}" == *"<"* || "${{ inputs.pr-ref }}" == *"\`"* ]]; then + echo "::error::Invalid characters in pr-ref - branch name contains potentially unsafe characters" + exit 1 + fi + + # Similar validation for base-ref + if [[ "${{ inputs.base-ref }}" == *";"* || "${{ inputs.base-ref }}" == *"&"* || + "${{ inputs.base-ref }}" == *"|"* || "${{ inputs.base-ref }}" == *">"* || + "${{ inputs.base-ref }}" == *"<"* || "${{ inputs.base-ref }}" == *"\`"* ]]; then + echo "::error::Invalid characters in base-ref - branch name contains potentially unsafe characters" + exit 1 + fi + + # Check if git is available - required for most functionality + if ! command -v git &> /dev/null; then + echo "::warning::Git is not installed - some functions may not work correctly" + fi + + # Display debug info if debug mode is enabled + if [[ "${{ inputs.debug-mode }}" == "true" ]]; then + echo "Debug mode enabled - verbose output will be shown" + echo "Working directory: $(pwd)" + echo "Git status: $(git status 2>&1 || echo 'Not a git repository')" + echo "Docs path: ${{ inputs.docs-path }}" + fi + + # Setup caching for repeated runs + - name: Setup caching + id: cache + shell: bash + run: | + # Generate a cache key based on the repository and action configuration + CACHE_INPUT_HASH=$(echo "${{ inputs.docs-path }},${{ inputs.max-scan-files }},${{ inputs.significant-words-threshold }}" | shasum -a 256 | cut -d ' ' -f 1) + CACHE_KEY="docs-analysis-${{ github.repository_id }}-${{ github.workflow }}-$CACHE_INPUT_HASH" + echo "cache_key=$CACHE_KEY" >> $GITHUB_OUTPUT + echo "Cache key: $CACHE_KEY" + + # Create temp directory for artifacts if it doesn't exist + mkdir -p .github/temp 2>/dev/null || true + + # Optimize git for large repositories + - name: Optimize git for large repositories + shell: bash + run: | + # Skip if git isn't available + if ! command -v git &> /dev/null; then + echo "::warning::Git not available, skipping optimization" + exit 0 + fi + + # Only apply these optimizations if we're in a git repository + if git rev-parse --is-inside-work-tree &>/dev/null; then + # Configure git for better performance with large repos + git config core.preloadIndex true + git config core.fsyncMethod batch + git config core.compression 9 + + # Verify configuration + echo "Git optimization applied:" + git config --get-regexp "core\.(preloadIndex|fsyncMethod|compression)" || echo "Failed to apply git optimizations" + + # Apply throttling for large repos if enabled + if [[ "${{ inputs.throttle-large-repos }}" == "true" ]]; then + REPO_SIZE=$(du -sm . 2>/dev/null | cut -f1 || echo "0") + if [[ "$REPO_SIZE" -gt 500 ]]; then + echo "Large repository detected ($REPO_SIZE MB) - applying throttling" + git config core.packedGitLimit 128m + git config core.packedGitWindowSize 128m + git config pack.windowMemory 128m + fi + fi + else + echo "::warning::Not in a git repository, skipping git optimizations" + fi + + # Detect if files changed match docs patterns + - name: Verify docs changes + id: verify + shell: bash + run: | + # Add error tracing for better debugging and recovery + trap 'echo "::error::Error occurred in verify docs changes at line $LINENO"' ERR + # Helper functions for better error handling and path sanitization + function handle_error() { + echo "::error::$1" + echo "docs_changed=false" >> $GITHUB_OUTPUT + exit 1 + } + + # More secure path sanitization with validation + function sanitize_path() { + local path="$1" + + # Check for path traversal attempts or absolute paths if needed + if [[ "$path" == *".."* || "$path" == "/"* ]]; then + echo "::error::Invalid path containing directory traversal patterns or absolute reference" + return 1 + fi + + # Sanitize the path - escape special characters + echo "$path" | sed 's/[;&|"`$]/\\&/g' + } + + # Retry function for git operations to handle potential rate limiting + # Uses direct command execution instead of eval for better security + function git_with_retry() { + local max_retries=3 + local retry_count=0 + + while [[ $retry_count -lt $max_retries ]]; do + if "$@"; then # Direct execution instead of eval + return 0 + fi + + retry_count=$((retry_count + 1)) + echo "Git operation failed, retry $retry_count of $max_retries" + sleep $((retry_count * 2)) + done + + echo "::warning::Git operation failed after $max_retries retries" + return 1 + } + + # Set defaults for outputs to avoid null values + echo "docs_changed=false" >> $GITHUB_OUTPUT + echo "docs_files_count=0" >> $GITHUB_OUTPUT + echo "words_added=0" >> $GITHUB_OUTPUT + echo "words_removed=0" >> $GITHUB_OUTPUT + echo "images_added=0" >> $GITHUB_OUTPUT + echo "images_modified=0" >> $GITHUB_OUTPUT + echo "images_deleted=0" >> $GITHUB_OUTPUT + echo "images_total=0" >> $GITHUB_OUTPUT + echo "image_names=" >> $GITHUB_OUTPUT + echo "manifest_changed=false" >> $GITHUB_OUTPUT + echo "format_only=false" >> $GITHUB_OUTPUT + echo "significant_change=false" >> $GITHUB_OUTPUT + echo "has_non_docs_changes=false" >> $GITHUB_OUTPUT + + # Enable debug output if requested + if [[ "${{ inputs.debug-mode }}" == "true" ]]; then + set -x + fi + + # Determine which files to analyze + if [[ -n "${{ inputs.changed-files }}" ]]; then + # Priority 1: Use files from tj-actions/changed-files + CHANGED_FILES=$(echo "${{ inputs.changed-files }}" | tr ',' '\n') + echo "Using files from tj-actions/changed-files" + elif [[ -n "${{ inputs.files-changed }}" ]]; then + # Priority 2: Use provided list of files (backward compatibility) + CHANGED_FILES=$(echo "${{ inputs.files-changed }}" | tr ',' '\n') + echo "Using provided list of changed files" + else + # Priority 3: Use git to determine changed files + # Skip if git isn't available + if ! command -v git &> /dev/null; then + echo "::warning::Git not available, cannot determine changed files" + exit 0 + fi + + # Skip if not in a git repository + if ! git rev-parse --is-inside-work-tree &>/dev/null; then + echo "::warning::Not in a git repository, cannot determine changed files" + exit 0 + fi + + BRANCH_NAME="${{ inputs.pr-ref }}" + BASE_REF="${{ inputs.base-ref }}" + + # Check if the branch exists + if ! git show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME"; then + # Try to fetch the branch if it doesn't exist + echo "Branch $BRANCH_NAME not found locally, fetching..." + git_with_retry git fetch origin "$BRANCH_NAME" --depth=5 || handle_error "Failed to fetch branch $BRANCH_NAME" + fi + + echo "Checking changed files between $BASE_REF and origin/$BRANCH_NAME" + CHANGED_FILES=$(git diff --name-only origin/$BASE_REF..origin/$BRANCH_NAME) + + if [[ "${{ inputs.debug-mode }}" == "true" ]]; then + echo "Files detected via git diff:" + echo "$CHANGED_FILES" + fi + fi + + if [[ -z "$CHANGED_FILES" ]]; then + echo "No files changed in this PR" + if [[ "${{ inputs.skip-if-no-docs }}" == "true" ]]; then + exit 0 + fi + fi + + # Check if manifest.json was modified - a key indicator for doc structure changes + DOCS_PATH="$(sanitize_path "${{ inputs.docs-path }}")" + MANIFEST_PATH="${DOCS_PATH}manifest.json" + MANIFEST_CHANGED=$(echo "$CHANGED_FILES" | grep -c "$MANIFEST_PATH" || true) + + if [[ $MANIFEST_CHANGED -gt 0 ]]; then + echo "docs/manifest.json was modified - likely a significant docs change" + echo "manifest_changed=true" >> $GITHUB_OUTPUT + + # Get the files referenced in the manifest diff if using git + if [[ -z "${{ inputs.files-changed }}" ]]; then + if command -v git &> /dev/null && git rev-parse --is-inside-work-tree &>/dev/null; then + MANIFEST_DIFF_FILES=$(git diff origin/$BASE_REF..origin/$BRANCH_NAME -- "$MANIFEST_PATH" | grep -E "^\+.*\"path\"" | grep -oE '\"[^\"]+\.md\"' | tr -d '"' || true) + if [[ -n "$MANIFEST_DIFF_FILES" ]]; then + echo "Found files referenced in manifest changes:" + echo "$MANIFEST_DIFF_FILES" + echo "manifest_changed_files<> $GITHUB_OUTPUT + echo "$MANIFEST_DIFF_FILES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + fi + fi + fi + else + echo "manifest_changed=false" >> $GITHUB_OUTPUT + fi + + # Identify docs files in the changes + DOCS_FILES=$(echo "$CHANGED_FILES" | grep -E "^$DOCS_PATH|^.*\.md$" || true) + NON_DOCS_FILES=$(echo "$CHANGED_FILES" | grep -v -E "^$DOCS_PATH|^.*\.md$" || true) + DOCS_DIR_FILES=$(echo "$CHANGED_FILES" | grep "^$DOCS_PATH" || true) + + # Check if we have non-docs changes for use in status messages + if [[ -n "$NON_DOCS_FILES" ]]; then + echo "has_non_docs_changes=true" >> $GITHUB_OUTPUT + + if [[ "${{ inputs.debug-mode }}" == "true" ]]; then + echo "Non-docs files changed:" + echo "$NON_DOCS_FILES" + fi + else + echo "has_non_docs_changes=false" >> $GITHUB_OUTPUT + fi + + # Output docs files for further processing + if [[ -n "$DOCS_FILES" ]]; then + echo "changed_docs_files<> $GITHUB_OUTPUT + echo "$DOCS_FILES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + fi + + # Always output docs directory files for preview link + if [[ -n "$DOCS_DIR_FILES" ]]; then + echo "docs_dir_files<> $GITHUB_OUTPUT + echo "$DOCS_DIR_FILES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + fi + + # Check if docs/ directory files are changed (these are what we want to preview) + if [[ -n "$DOCS_DIR_FILES" ]]; then + # We have docs/ changes, so we should generate a preview + echo "docs_changed=true" >> $GITHUB_OUTPUT + + # If there are also non-docs files, we'll just print a notice but still proceed + if [[ -n "$NON_DOCS_FILES" ]]; then + echo "⚠️ PR contains both docs/ changes and other file changes." + else + echo "✅ All changes are docs-related, proceeding safely." + fi + + # Calculate documentation metrics if using git + if [[ -z "${{ inputs.files-changed }}" ]]; then + if command -v git &> /dev/null && git rev-parse --is-inside-work-tree &>/dev/null; then + # Analyze content changes vs. format changes + CONTENT_CHANGED=$(git diff --word-diff=porcelain origin/$BASE_REF..origin/$BRANCH_NAME -- $DOCS_PATH | grep -E "^\+[^+]|\-[^-]" | wc -l | tr -d ' ') + FORMAT_ONLY=false + if [[ $CONTENT_CHANGED -eq 0 ]]; then + echo "Only formatting changes detected (no content changes)" + FORMAT_ONLY=true + fi + echo "format_only=$FORMAT_ONLY" >> $GITHUB_OUTPUT + + # Calculate documentation metrics + DOCS_FILES_COUNT=$(echo "$CHANGED_FILES" | grep -E "^$DOCS_PATH|^.*\.md$" | wc -l | tr -d ' ') + WORDS_ADDED=$(git diff --word-diff=porcelain origin/$BASE_REF..origin/$BRANCH_NAME -- $DOCS_PATH | grep -E "^\+" | wc -w | tr -d ' ') + WORDS_REMOVED=$(git diff --word-diff=porcelain origin/$BASE_REF..origin/$BRANCH_NAME -- $DOCS_PATH | grep -E "^\-" | wc -w | tr -d ' ') + + echo "docs_files_count=$DOCS_FILES_COUNT" >> $GITHUB_OUTPUT + echo "words_added=$WORDS_ADDED" >> $GITHUB_OUTPUT + echo "words_removed=$WORDS_REMOVED" >> $GITHUB_OUTPUT + + # Improve image tracking by detecting added, modified, and removed images + IMAGE_PATHS=$(git diff --name-status origin/$BASE_REF..origin/$BRANCH_NAME | grep -E "\.(png|jpg|jpeg|gif|svg|webp)$" || echo "") + IMAGE_ADDED=$(echo "$IMAGE_PATHS" | grep -c "^A" || true) + IMAGE_MODIFIED=$(echo "$IMAGE_PATHS" | grep -c "^M" || true) + IMAGE_DELETED=$(echo "$IMAGE_PATHS" | grep -c "^D" || true) + IMAGE_TOTAL=$((IMAGE_ADDED + IMAGE_MODIFIED + IMAGE_DELETED)) + IMAGE_NAMES="" + + # Capture image names for display in the report + if [[ $IMAGE_TOTAL -gt 0 ]]; then + IMAGE_NAMES=$(echo "$IMAGE_PATHS" | grep -E "\.(png|jpg|jpeg|gif|svg|webp)$" | awk '{print $2}' | tr '\n' ',' | sed 's/,$//') + echo "image_names=$IMAGE_NAMES" >> $GITHUB_OUTPUT + echo "Found $IMAGE_TOTAL image changes: +$IMAGE_ADDED modified:$IMAGE_MODIFIED -$IMAGE_DELETED" + echo "Images: $IMAGE_NAMES" + fi + + echo "images_added=$IMAGE_ADDED" >> $GITHUB_OUTPUT + echo "images_modified=$IMAGE_MODIFIED" >> $GITHUB_OUTPUT + echo "images_deleted=$IMAGE_DELETED" >> $GITHUB_OUTPUT + echo "images_total=$IMAGE_TOTAL" >> $GITHUB_OUTPUT + + # Determine if this is a significant docs change for prioritization + if [[ $WORDS_ADDED -gt ${{ inputs.significant-words-threshold }} || $MANIFEST_CHANGED -gt 0 || $IMAGE_TOTAL -gt 1 ]]; then + echo "significant_change=true" >> $GITHUB_OUTPUT + + if [[ $IMAGE_TOTAL -gt 1 ]]; then + echo "⭐ This PR contains significant image changes ($IMAGE_TOTAL images)" + echo "image_focused=true" >> $GITHUB_OUTPUT + elif [[ $MANIFEST_CHANGED -gt 0 ]]; then + echo "⭐ This PR contains structure changes (manifest.json modified)" + echo "image_focused=false" >> $GITHUB_OUTPUT + else + echo "⭐ This PR contains significant documentation changes ($WORDS_ADDED words added)" + echo "image_focused=false" >> $GITHUB_OUTPUT + fi + else + echo "significant_change=false" >> $GITHUB_OUTPUT + echo "image_focused=false" >> $GITHUB_OUTPUT + fi + else + # Fallback for non-git environments + echo "::warning::Git not available for document metrics, using basic file counting" + DOCS_FILES_COUNT=$(echo "$DOCS_FILES" | wc -l | tr -d ' ') + echo "docs_files_count=$DOCS_FILES_COUNT" >> $GITHUB_OUTPUT + fi + else + # If using files-changed input, just count the files + DOCS_FILES_COUNT=$(echo "$DOCS_FILES" | wc -l | tr -d ' ') + echo "docs_files_count=$DOCS_FILES_COUNT" >> $GITHUB_OUTPUT + fi + else + if [[ -n "$DOCS_FILES" ]]; then + # We have .md files outside docs/ directory + echo "docs_changed=true" >> $GITHUB_OUTPUT + echo "Found markdown changes outside docs/ directory." + + # Count the files + DOCS_FILES_COUNT=$(echo "$DOCS_FILES" | wc -l | tr -d ' ') + echo "docs_files_count=$DOCS_FILES_COUNT" >> $GITHUB_OUTPUT + else + echo "⚠️ No documentation files changed." + echo "docs_changed=false" >> $GITHUB_OUTPUT + fi + fi + + # Output a summary of changes for the job log + DOCS_FILES_COUNT=$(echo "$CHANGED_FILES" | grep -E "^$DOCS_PATH|^.*\.md$" | wc -l | tr -d ' ') + TOTAL_FILES_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ') + echo "PR changes $DOCS_FILES_COUNT docs files out of $TOTAL_FILES_COUNT total files" + + # Disable debug mode if it was enabled + if [[ "${{ inputs.debug-mode }}" == "true" ]]; then + set +x + fi + + # Analyze document structure for files that have been changed + - name: Analyze document structure + id: analyze_structure + if: steps.verify.outputs.docs_changed == 'true' + shell: bash + run: | + # Create a temporary directory for analysis artifacts if needed + mkdir -p .github/temp 2>/dev/null || true + + # Enable debug output if requested + if [[ "${{ inputs.debug-mode }}" == "true" ]]; then + set -x + fi + + # Add error tracing for better debugging and recovery + trap 'echo "::error::Error occurred in document structure analysis at line $LINENO"' ERR + + # Helper functions + # More secure path sanitization with validation + function sanitize_path() { + local path="$1" + + # Check for path traversal attempts or absolute paths if needed + if [[ "$path" == *".."* || "$path" == "/"* ]]; then + echo "::error::Invalid path containing directory traversal patterns or absolute reference" + return 1 + fi + + # Sanitize the path - escape special characters + echo "$path" | sed 's/[;&|"`$]/\\&/g' + } + + function json_escape() { + # More robust JSON escaping using Python if available + if command -v python3 &>/dev/null; then + python3 -c "import json, sys; print(json.dumps(sys.argv[1]))" "$1" + else + # Fallback to basic escaping + echo "$1" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' + fi + } + + # Extract document structure information + DOC_TITLES=() + DOC_STRUCTURE=() + + # Files to analyze - either specified files or detect changed docs files + FILES_TO_ANALYZE="${{ steps.verify.outputs.changed_docs_files }}" + if [[ -z "$FILES_TO_ANALYZE" ]]; then + # Fallback to all markdown files in docs directory if no specific files + DOCS_PATH="$(sanitize_path "${{ inputs.docs-path }}")" + FILES_TO_ANALYZE=$(find $DOCS_PATH -name "*.md" | head -${{ inputs.max-scan-files }}) + fi + + # Limit the number of files to analyze in detail for performance + MAX_FILES="${{ inputs.max-files-to-analyze }}" + FILES_TO_ANALYZE=$(echo "$FILES_TO_ANALYZE" | head -$MAX_FILES) + + # Create JSON structure with better error handling + if command -v python3 &>/dev/null; then + # Use the external Python script file for more reliable JSON handling + if [[ -f ".github/actions/docs-analysis/analyze_docs.py" ]]; then + cat "$FILES_TO_ANALYZE" | python3 .github/actions/docs-analysis/analyze_docs.py > .github/temp/doc_structure.json + else + echo "::warning::Could not find analyze_docs.py script, falling back to bash-only approach" + # Fallback to bash if Python isn't available + echo "{" > .github/temp/doc_structure.json + FIRST_FILE=true + + # Process each file + while IFS= read -r file; do + if [[ -n "$file" && -f "$file" && "$file" == *.md ]]; then + # Extract document title (first heading) + TITLE=$(head -50 "$file" | grep -E "^# " | head -1 | sed 's/^# //') + + # Count headings at each level with better error handling + H1_COUNT=$(grep -c "^# " "$file" 2>/dev/null || echo "0") + H2_COUNT=$(grep -c "^## " "$file" 2>/dev/null || echo "0") + H3_COUNT=$(grep -c "^### " "$file" 2>/dev/null || echo "0") + + # Skip separator for first file + if [[ "$FIRST_FILE" == "true" ]]; then + FIRST_FILE=false + else + echo "," >> .github/temp/doc_structure.json + fi + + # Add to JSON structure - sanitize file for JSON + FILE_JSON=$(json_escape "$file") + TITLE_JSON=$(json_escape "${TITLE:-Untitled}") + + echo " $FILE_JSON: {" >> .github/temp/doc_structure.json + echo " \"title\": $TITLE_JSON," >> .github/temp/doc_structure.json + echo " \"headings\": {" >> .github/temp/doc_structure.json + echo " \"h1\": $H1_COUNT," >> .github/temp/doc_structure.json + echo " \"h2\": $H2_COUNT," >> .github/temp/doc_structure.json + echo " \"h3\": $H3_COUNT" >> .github/temp/doc_structure.json + echo " }" >> .github/temp/doc_structure.json + echo " }" >> .github/temp/doc_structure.json + + echo "Analyzed $file: H1=$H1_COUNT, H2=$H2_COUNT, H3=$H3_COUNT, Title='${TITLE:-Untitled}'" + fi + done <<< "$FILES_TO_ANALYZE" + + # Close JSON object + echo "}" >> .github/temp/doc_structure.json + fi + else + # Fallback to bash if Python isn't available + echo "{" > .github/temp/doc_structure.json + FIRST_FILE=true + + # Process each file + while IFS= read -r file; do + if [[ -n "$file" && -f "$file" && "$file" == *.md ]]; then + # Extract document title (first heading) + TITLE=$(head -50 "$file" | grep -E "^# " | head -1 | sed 's/^# //') + + # Count headings at each level with better error handling + H1_COUNT=$(grep -c "^# " "$file" 2>/dev/null || echo "0") + H2_COUNT=$(grep -c "^## " "$file" 2>/dev/null || echo "0") + H3_COUNT=$(grep -c "^### " "$file" 2>/dev/null || echo "0") + + # Skip separator for first file + if [[ "$FIRST_FILE" == "true" ]]; then + FIRST_FILE=false + else + echo "," >> .github/temp/doc_structure.json + fi + + # Add to JSON structure - sanitize file for JSON + FILE_JSON=$(json_escape "$file") + TITLE_JSON=$(json_escape "${TITLE:-Untitled}") + + echo " $FILE_JSON: {" >> .github/temp/doc_structure.json + echo " \"title\": $TITLE_JSON," >> .github/temp/doc_structure.json + echo " \"headings\": {" >> .github/temp/doc_structure.json + echo " \"h1\": $H1_COUNT," >> .github/temp/doc_structure.json + echo " \"h2\": $H2_COUNT," >> .github/temp/doc_structure.json + echo " \"h3\": $H3_COUNT" >> .github/temp/doc_structure.json + echo " }" >> .github/temp/doc_structure.json + echo " }" >> .github/temp/doc_structure.json + + echo "Analyzed $file: H1=$H1_COUNT, H2=$H2_COUNT, H3=$H3_COUNT, Title='${TITLE:-Untitled}'" + fi + done <<< "$FILES_TO_ANALYZE" + + # Close JSON object + echo "}" >> .github/temp/doc_structure.json + fi + + # Set outputs + if [[ -s .github/temp/doc_structure.json ]]; then + DOC_STRUCTURE=$(cat .github/temp/doc_structure.json) + echo "doc_structure<> $GITHUB_OUTPUT + echo "$DOC_STRUCTURE" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + if [[ "$DOC_STRUCTURE" != "{}" ]]; then + echo "document_structure_found=true" >> $GITHUB_OUTPUT + echo "Found document structure for improved context" + else + echo "document_structure_found=false" >> $GITHUB_OUTPUT + echo "No document structure found" + fi + else + echo "document_structure_found=false" >> $GITHUB_OUTPUT + echo "No document structure found" + fi + + # Disable debug mode if it was enabled + if [[ "${{ inputs.debug-mode }}" == "true" ]]; then + set +x + fi + + # Find the most changed files for providing direct links + - name: Find files with most changes + id: find_changed_files + if: steps.verify.outputs.docs_changed == 'true' + shell: bash + run: | + # Add error tracing for better debugging and recovery + trap 'echo "::error::Error occurred in finding changed files at line $LINENO"' ERR + + # Helper functions + # More secure path sanitization with validation + function sanitize_path() { + local path="$1" + + # Check for path traversal attempts or absolute paths if needed + if [[ "$path" == *".."* || "$path" == "/"* ]]; then + echo "::error::Invalid path containing directory traversal patterns or absolute reference" + return 1 + fi + + # Sanitize the path - escape special characters + echo "$path" | sed 's/[;&|"`$]/\\&/g' + } + + # Only run if we have docs changes + CHANGED_FILES="${{ steps.verify.outputs.changed_docs_files }}" + DIFF_TARGET="origin/${{ inputs.pr-ref }}" + IS_IMAGE_FOCUSED="${{ steps.verify.outputs.image_focused }}" + BASE_REF="${{ inputs.base-ref }}" + BRANCH_NAME="${{ inputs.pr-ref }}" + DOCS_PATH="$(sanitize_path "${{ inputs.docs-path }}")" + + if [[ -z "$CHANGED_FILES" ]]; then + echo "No documentation files changed." + echo "has_changes=false" >> $GITHUB_OUTPUT + exit 0 + else + echo "Found changed documentation files, proceeding with analysis." + echo "has_changes=true" >> $GITHUB_OUTPUT + + # Write file count to output + FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ') + echo "changed_file_count=$FILE_COUNT" >> $GITHUB_OUTPUT + fi + + # Find the file with the most additions + echo "Analyzing files to find the one with most additions..." + MOST_CHANGED="" + MAX_ADDITIONS=0 + MOST_SIGNIFICANT_IMAGE="" + + # First, check if this is an image-focused PR to prioritize images + if [[ "$IS_IMAGE_FOCUSED" == "true" && -n "$CHANGED_FILES" ]]; then + if command -v git &> /dev/null && git rev-parse --is-inside-work-tree &>/dev/null; then + echo "This is an image-focused PR, prioritizing image files in analysis" + + # Find the most significant image change + IMAGE_FILES=$(git diff --name-status origin/$BASE_REF..$DIFF_TARGET | grep -E ".(png|jpg|jpeg|gif|svg|webp)$" | awk '{print $2}') + + if [[ -n "$IMAGE_FILES" ]]; then + # Find the largest added/modified image by looking at file size + while IFS= read -r img_file; do + if [[ -f "$img_file" ]]; then + # Get file size in bytes (compatible with both macOS and Linux with fallbacks) + FILE_SIZE=$(stat -f "%z" "$img_file" 2>/dev/null || stat -c "%s" "$img_file" 2>/dev/null || wc -c < "$img_file" 2>/dev/null || echo "0") + + # Find containing markdown file to link to + # Look for filenames that include the image basename + IMAGE_BASENAME=$(basename "$img_file") + CONTAINING_MD=$(grep -l "$IMAGE_BASENAME" $(find $DOCS_PATH -name "*.md") 2>/dev/null | head -1) + + if [[ -n "$CONTAINING_MD" ]]; then + echo "Found image $img_file ($FILE_SIZE bytes) referenced in $CONTAINING_MD" + if [[ -z "$MOST_SIGNIFICANT_IMAGE" || $FILE_SIZE -gt $MAX_ADDITIONS ]]; then + MOST_SIGNIFICANT_IMAGE="$img_file" + MOST_CHANGED="$CONTAINING_MD" + MAX_ADDITIONS=$FILE_SIZE + fi + else + echo "Found image $img_file ($FILE_SIZE bytes) but no matching markdown file" + if [[ -z "$MOST_SIGNIFICANT_IMAGE" || $FILE_SIZE -gt $MAX_ADDITIONS ]]; then + MOST_SIGNIFICANT_IMAGE="$img_file" + MOST_CHANGED="" + MAX_ADDITIONS=$FILE_SIZE + fi + fi + fi + done <<< "$IMAGE_FILES" + + if [[ -n "$MOST_SIGNIFICANT_IMAGE" ]]; then + echo "Most significant image: $MOST_SIGNIFICANT_IMAGE ($MAX_ADDITIONS bytes)" + echo "most_significant_image=$MOST_SIGNIFICANT_IMAGE" >> $GITHUB_OUTPUT + + # If we found a containing markdown file, use that for the URL path + if [[ -n "$MOST_CHANGED" ]]; then + echo "Referenced in markdown file: $MOST_CHANGED" + + # Convert path to URL path by removing the file extension and default index files + URL_PATH=$(echo "$MOST_CHANGED" | sed -E 's/\.md$//' | sed -E 's/\/index$//') + echo "URL path for markdown file: $URL_PATH" + + echo "most_changed_file=$MOST_CHANGED" >> $GITHUB_OUTPUT + echo "most_changed_url_path=$URL_PATH" >> $GITHUB_OUTPUT + echo "most_changed_additions=$MAX_ADDITIONS" >> $GITHUB_OUTPUT + fi + fi + fi + + # If we haven't found a significant image link, fall back to default behavior + if [[ -z "$MOST_CHANGED" ]]; then + echo "No significant image reference found, falling back to regular analysis" + else + # We've found our image connection, so we can exit this step + # Disable debug mode if it was enabled + if [[ "${{ inputs.debug-mode }}" == "true" ]]; then + set +x + fi + exit 0 + fi + fi + fi + + # Standard analysis for finding the most changed file if not already found + if [[ -z "$MOST_CHANGED" ]]; then + MAX_ADDITIONS=0 + + while IFS= read -r file; do + if [[ -n "$file" && -f "$file" ]]; then + # Get additions count for this file + if command -v git &> /dev/null && git rev-parse --is-inside-work-tree &>/dev/null && [[ -z "${{ inputs.files-changed }}" ]]; then + # Use git diff if comparing branches + ADDITIONS=$(git diff --numstat origin/$BASE_REF..$DIFF_TARGET -- "$file" | awk '{print $1}') + else + # Fallback to counting lines if just analyzing files + ADDITIONS=$(wc -l < "$file" 2>/dev/null | tr -d ' ' || echo "0") + fi + + if (( ADDITIONS > MAX_ADDITIONS && ADDITIONS > 0 )); then + MAX_ADDITIONS=$ADDITIONS + MOST_CHANGED=$file + fi + fi + done <<< "$CHANGED_FILES" + + if [[ -n "$MOST_CHANGED" ]]; then + echo "Most changed file: $MOST_CHANGED with $MAX_ADDITIONS additions" + + # Convert path to URL path by removing the file extension and default index files + URL_PATH=$(echo $MOST_CHANGED | sed -E 's/\.md$//' | sed -E 's/\/index$//') + echo "URL path for most changed file: $URL_PATH" + + echo "most_changed_file=$MOST_CHANGED" >> $GITHUB_OUTPUT + echo "most_changed_url_path=$URL_PATH" >> $GITHUB_OUTPUT + echo "most_changed_additions=$MAX_ADDITIONS" >> $GITHUB_OUTPUT + else + echo "Could not determine most changed file" + fi + fi + + # Disable debug mode if it was enabled + if [[ "${{ inputs.debug-mode }}" == "true" ]]; then + set +x + fi + + # Capture execution time for performance tracking + - name: Calculate execution time + id: execution_timing + shell: bash + run: | + END_TIME=$(date +%s) + START_TIME="${{ steps.timing.outputs.start_time }}" + DURATION=$((END_TIME - START_TIME)) + echo "duration=$DURATION" >> $GITHUB_OUTPUT + + # Output for CI monitoring systems + if [[ $DURATION -gt 30 ]]; then + echo "::warning::Docs analysis took ${DURATION}s to complete - consider optimizing" + else + echo "::notice::Docs analysis completed in ${DURATION}s" + fi + + # Create execution metrics JSON for potential monitoring integration + mkdir -p .github/temp + cat > .github/temp/docs-analysis-metrics.json << EOF + { + "execution_time": $DURATION, + "timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")", + "repository": "${{ github.repository }}", + "workflow": "${{ github.workflow }}", + "action": "docs-analysis" + } + EOF \ No newline at end of file diff --git a/.github/actions/docs-analysis/analyze_docs.py b/.github/actions/docs-analysis/analyze_docs.py new file mode 100755 index 0000000000000..6a1e7e7fd86fa --- /dev/null +++ b/.github/actions/docs-analysis/analyze_docs.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +import sys +import json +import os +import re + +files_to_analyze = sys.stdin.read().strip().split('\n') +doc_structure = {} + +for file_path in files_to_analyze: + if not file_path or not file_path.endswith('.md') or not os.path.isfile(file_path): + continue + + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Extract title (first h1) + title_match = re.search(r'^# (.+)$', content, re.MULTILINE) + title = title_match.group(1) if title_match else 'Untitled' + + # Count headings + h1_count = len(re.findall(r'^# ', content, re.MULTILINE)) + h2_count = len(re.findall(r'^## ', content, re.MULTILINE)) + h3_count = len(re.findall(r'^### ', content, re.MULTILINE)) + + doc_structure[file_path] = { + 'title': title, + 'headings': { + 'h1': h1_count, + 'h2': h2_count, + 'h3': h3_count + } + } + + print(f'Analyzed {file_path}: H1={h1_count}, H2={h2_count}, H3={h3_count}, Title="{title}"', file=sys.stderr) + except Exception as e: + print(f'Error analyzing {file_path}: {str(e)}', file=sys.stderr) + +# Write JSON output +with open('.github/temp/doc_structure.json', 'w', encoding='utf-8') as f: + json.dump(doc_structure, f, indent=2) + +print(json.dumps(doc_structure)) \ No newline at end of file diff --git a/.github/workflows/docs-ci.yaml b/.github/workflows/docs-ci.yaml index 7bbadbe3aba92..fe79370819804 100644 --- a/.github/workflows/docs-ci.yaml +++ b/.github/workflows/docs-ci.yaml @@ -1,5 +1,10 @@ name: Docs CI +# This workflow runs linting and formatting checks on documentation files. +# It leverages the shared docs-analysis composite action for detecting changes +# and integrates with other documentation workflows through shared outputs. +# Security features ensure safe handling of files and commands. + on: push: branches: @@ -8,12 +13,14 @@ on: - "docs/**" - "**.md" - ".github/workflows/docs-ci.yaml" + - ".github/actions/docs-analysis/**" pull_request: paths: - "docs/**" - "**.md" - ".github/workflows/docs-ci.yaml" + - ".github/actions/docs-analysis/**" permissions: contents: read @@ -21,9 +28,23 @@ permissions: jobs: docs: runs-on: ubuntu-latest + outputs: + docs_changed: ${{ steps.docs-analysis.outputs.docs-changed }} + docs_files_count: ${{ steps.docs-analysis.outputs.docs-files-count }} + words_added: ${{ steps.docs-analysis.outputs.words-added }} + words_removed: ${{ steps.docs-analysis.outputs.words-removed }} + images_changed: ${{ steps.docs-analysis.outputs.images-total }} + significant_change: ${{ steps.docs-analysis.outputs.significant-change }} steps: + - name: Harden Runner + uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + with: + egress-policy: audit + - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 50 # Increased for better detection of doc changes - name: Setup Node uses: ./.github/actions/setup-node @@ -35,14 +56,113 @@ jobs: docs/** **.md separator: "," + fetch_depth: 50 + since_last_remote_commit: ${{ github.event_name == 'push' }} + + # Use our composite action to analyze documentation changes + - name: Analyze documentation changes + id: docs-analysis + uses: ./.github/actions/docs-analysis + with: + docs-path: "docs/" + changed-files: ${{ steps.changed-files.outputs.all_changed_files }} + files-pattern: "docs/**|**.md" + debug-mode: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug == 'true' || 'false' }} + # Enable throttling for large repositories + throttle-large-repos: 'true' + # Default performance optimization values + max-scan-files: '100' + max-files-to-analyze: '20' - name: lint - if: steps.changed-files.outputs.any_changed == 'true' + if: steps.docs-analysis.outputs.docs-changed == 'true' run: | pnpm exec markdownlint-cli2 ${{ steps.changed-files.outputs.all_changed_files }} - name: fmt - if: steps.changed-files.outputs.any_changed == 'true' + if: steps.docs-analysis.outputs.docs-changed == 'true' run: | # markdown-table-formatter requires a space separated list of files echo ${{ steps.changed-files.outputs.all_changed_files }} | tr ',' '\n' | pnpm exec markdown-table-formatter --check + + # Display metrics about documentation changes (only on PRs) + - name: Documentation metrics summary + if: github.event_name == 'pull_request' && steps.docs-analysis.outputs.docs-changed == 'true' + run: | + echo "## Documentation Changes Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY + echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY + echo "| Files changed | ${{ steps.docs-analysis.outputs.docs-files-count }} |" >> $GITHUB_STEP_SUMMARY + echo "| Words added | ${{ steps.docs-analysis.outputs.words-added }} |" >> $GITHUB_STEP_SUMMARY + echo "| Words removed | ${{ steps.docs-analysis.outputs.words-removed }} |" >> $GITHUB_STEP_SUMMARY + echo "| Processing time | ${{ steps.docs-analysis.outputs.execution-time }}s |" >> $GITHUB_STEP_SUMMARY + + if [[ "${{ steps.docs-analysis.outputs.images-total }}" != "0" ]]; then + echo "| Images changed | ${{ steps.docs-analysis.outputs.images-total }} |" >> $GITHUB_STEP_SUMMARY + + # Add more detailed image metrics if available + if [[ "${{ steps.docs-analysis.outputs.images-added }}" != "0" || "${{ steps.docs-analysis.outputs.images-modified }}" != "0" || "${{ steps.docs-analysis.outputs.images-deleted }}" != "0" ]]; then + IMAGES_DETAIL="" + if [[ "${{ steps.docs-analysis.outputs.images-added }}" != "0" ]]; then + IMAGES_DETAIL="${{ steps.docs-analysis.outputs.images-added }} added" + fi + if [[ "${{ steps.docs-analysis.outputs.images-modified }}" != "0" ]]; then + if [[ -n "$IMAGES_DETAIL" ]]; then + IMAGES_DETAIL="$IMAGES_DETAIL, ${{ steps.docs-analysis.outputs.images-modified }} modified" + else + IMAGES_DETAIL="${{ steps.docs-analysis.outputs.images-modified }} modified" + fi + fi + if [[ "${{ steps.docs-analysis.outputs.images-deleted }}" != "0" ]]; then + if [[ -n "$IMAGES_DETAIL" ]]; then + IMAGES_DETAIL="$IMAGES_DETAIL, ${{ steps.docs-analysis.outputs.images-deleted }} deleted" + else + IMAGES_DETAIL="${{ steps.docs-analysis.outputs.images-deleted }} deleted" + fi + fi + echo "| Images detail | $IMAGES_DETAIL |" >> $GITHUB_STEP_SUMMARY + fi + fi + + if [[ "${{ steps.docs-analysis.outputs.manifest-changed }}" == "true" ]]; then + echo "| Structure changes | Yes (manifest.json modified) |" >> $GITHUB_STEP_SUMMARY + fi + + if [[ "${{ steps.docs-analysis.outputs.format-only }}" == "true" ]]; then + echo "| Format only | Yes (no content changes) |" >> $GITHUB_STEP_SUMMARY + fi + + # Add most changed file info if available + if [[ "${{ steps.docs-analysis.outputs.most-changed-file }}" != "" ]]; then + echo "| Most changed file | \`${{ steps.docs-analysis.outputs.most-changed-file }}\` |" >> $GITHUB_STEP_SUMMARY + fi + + # Create job summary for GitHub Actions UI + - name: Job status summary + if: always() + run: | + STATUS="${{ job.status }}" + + if [[ "$STATUS" == "success" ]]; then + echo "## ✅ Documentation checks passed" >> $GITHUB_STEP_SUMMARY + else + echo "## ❌ Documentation checks failed" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "Ran with:" >> $GITHUB_STEP_SUMMARY + echo "- Docs Analysis version: $(cd .github/actions/docs-analysis && git rev-parse --short HEAD || echo 'unknown')" >> $GITHUB_STEP_SUMMARY + echo "- Event: ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY + + # Output useful links for debugging + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "- [View PR](https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }})" >> $GITHUB_STEP_SUMMARY + fi + + # Output performance metrics + if [[ -f ".github/temp/docs-analysis-metrics.json" ]]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Performance Metrics" >> $GITHUB_STEP_SUMMARY + echo "$(cat .github/temp/docs-analysis-metrics.json | grep -o '"execution_time": [0-9]*' | cut -d' ' -f2) seconds to analyze documentation" >> $GITHUB_STEP_SUMMARY + fi \ No newline at end of file diff --git a/.github/workflows/docs-preview-link.yml b/.github/workflows/docs-preview-link.yml new file mode 100644 index 0000000000000..e7971ca5e6882 --- /dev/null +++ b/.github/workflows/docs-preview-link.yml @@ -0,0 +1,1637 @@ +name: Docs Preview Link + +# This workflow adds documentation preview links to PRs that modify docs content. +# It integrates with the wider CI system while ensuring security for fork PRs. +# +# Primary features: +# 1. Generates preview links in the format https://coder.com/docs/@branch_name +# 2. Adds direct links to changed files & sections with most modifications +# 3. Safely handles fork PRs through multi-stage verification +# 4. Coordinates with other CI checks via unified status namespace +# 5. Supports slash commands for manual triggering + +on: + # For automatic addition of preview links on new PRs and PR state changes + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, labeled] + paths: + - 'docs/**' + - '**.md' + # For manual triggering via comment commands + issue_comment: + types: [created] + # Allow manual runs from the GitHub Actions UI (for maintainers) + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to generate preview link for' + required: true + delay: + description: 'Delay start by N seconds (for CI load management)' + required: false + default: '0' + priority: + description: 'Priority level (normal, high)' + required: false + default: 'normal' + force: + description: 'Force preview generation even if no docs changes (for maintainers)' + required: false + default: 'false' + self_test: + description: 'Run workflow self-test to validate configuration' + required: false + default: 'false' + type: boolean + +# Prevent concurrent workflow runs on the same PR to avoid conflicts +# This reduces redundant runs triggered by multiple events +# cancel-in-progress ensures only the most recent run continues +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} + cancel-in-progress: true + +# Environment configuration with expanded options +env: + # Enable fast track processing for docs-only PRs + DOCS_ONLY_PR: ${{ contains(github.event.pull_request.labels.*.name, 'docs-only') }} + DOCS_FAST_TRACK: 'true' + # Status check namespace for better integration with other CI checks + STATUS_CHECK_PREFIX: 'coder/docs' + # Organization-level cache strategy for sharing with other doc workflows + CACHE_PREFIX: 'docs-${{ github.repository_owner }}' + # API retry configuration + MAX_API_RETRIES: '3' + API_RETRY_DELAY: '2' + # Documentation paths configuration + DOCS_PRIMARY_PATH: 'docs/' + DOCS_FILE_PATTERNS: '^docs/|^.*\.md$' + # Documentation metrics thresholds for highlighting significant changes + SIGNIFICANT_WORDS_THRESHOLD: '100' + # Throttling controls for synchronize events + THROTTLE_DOCS_CHECK: ${{ github.event_name == 'pull_request_target' && github.event.action == 'synchronize' }} + # PR size detection for automatic throttling + LARGE_PR_THRESHOLD: '500' + # Repository and app information + DOCS_URL_BASE: 'https://coder.com/docs' + # Control the info disclosure level based on repo visibility + SECURITY_LEVEL: ${{ github.event.repository.private && 'strict' || 'standard' }} + # Scan depth control + MAX_SCAN_FILES: '100' + # Add rate limiting for external URL creation + RATE_LIMIT_REQUESTS: '10' + # Timeout constraints + COMMAND_TIMEOUT: '30s' + +# Default timeout for the entire workflow (5 minutes) +defaults: + run: + timeout-minutes: 5 + +jobs: + # Conditionally delay the workflow start to manage CI load + # Self-test for workflow validation (only runs when explicitly triggered) + validate-workflow: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' && github.event.inputs.self_test == 'true' + steps: + - name: Validate workflow configuration + run: | + echo "Running workflow self-test..." + + # Verify required environment variables + for var in DOCS_URL_BASE STATUS_CHECK_PREFIX; do + if [[ -z "${{ env[var] }}" ]]; then + echo "::error::Required environment variable $var is not set" + exit 1 + else + echo "✓ Environment variable $var is set: ${{ env[var] }}" + fi + done + + # Check for required permissions + if [[ "${{ github.token }}" == "***" ]]; then + echo "✓ GitHub token is available" + else + echo "::error::GitHub token is not properly configured" + exit 1 + fi + + # Verify repository configuration + echo "Repository: ${{ github.repository }}" + echo "Repository visibility: ${{ github.event.repository.private == true && 'private' || 'public' }}" + echo "Security level: ${{ env.SECURITY_LEVEL }}" + + echo "::notice::Self-test completed successfully" + + delay-start: + runs-on: ubuntu-latest + needs: [validate-workflow] + if: | + always() && + (github.event.inputs.delay != '' && github.event.inputs.delay != '0' || + (github.event_name == 'pull_request_target' && github.event.action == 'synchronize' && github.event.pull_request.additions + github.event.pull_request.deletions > 500)) + steps: + - name: Calculate delay time + id: delay_calc + run: | + if [[ "${{ github.event.inputs.delay }}" != "" && "${{ github.event.inputs.delay }}" != "0" ]]; then + DELAY="${{ github.event.inputs.delay }}" + echo "reason=Manually specified delay" >> $GITHUB_OUTPUT + elif [[ "${{ github.event_name }}" == "pull_request_target" && "${{ github.event.action }}" == "synchronize" ]]; then + PR_SIZE=${{ github.event.pull_request.additions + github.event.pull_request.deletions }} + if [[ $PR_SIZE -gt ${{ env.LARGE_PR_THRESHOLD }} ]]; then + # Scale delay based on PR size + DELAY=$(( PR_SIZE / 500 * 20 )) + # Cap at 2 minutes max delay + DELAY=$(( DELAY > 120 ? 120 : DELAY )) + echo "reason=Large PR size ($PR_SIZE changes)" >> $GITHUB_OUTPUT + else + DELAY=0 + fi + else + DELAY=0 + fi + echo "delay_time=$DELAY" >> $GITHUB_OUTPUT + + - name: Delay workflow start + if: steps.delay_calc.outputs.delay_time != '0' + run: | + DELAY="${{ steps.delay_calc.outputs.delay_time }}" + REASON="${{ steps.delay_calc.outputs.reason }}" + echo "Delaying workflow start by $DELAY seconds for CI load management" + echo "Reason: $REASON" + sleep $DELAY + echo "Proceeding with workflow execution" + + verify-docs-changes: + needs: [validate-workflow, delay-start] + runs-on: ubuntu-latest + timeout-minutes: 3 # Reduced timeout for verification step + if: | + always() && + (needs.validate-workflow.result == 'success' || needs.validate-workflow.result == 'skipped') + permissions: + contents: read + pull-requests: read + checks: write # For creating check runs + statuses: write # For creating commit statuses + if: | + always() && ( + (github.event_name == 'pull_request_target' && + (github.event.pull_request.draft == false || contains(github.event.pull_request.labels.*.name, 'run-checks-on-draft'))) || + (github.event_name == 'workflow_dispatch') || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && + (contains(github.event.comment.body, '/docs-preview') || contains(github.event.comment.body, '/docs-help'))) + ) + outputs: + docs_changed: ${{ steps.docs-analysis.outputs.docs-changed }} + pr_number: ${{ steps.pr_info.outputs.pr_number }} + branch_name: ${{ steps.pr_info.outputs.branch_name }} + repo_owner: ${{ steps.pr_info.outputs.repo_owner }} + is_fork: ${{ steps.pr_info.outputs.is_fork }} + is_comment: ${{ steps.pr_info.outputs.is_comment }} + is_manual: ${{ steps.pr_info.outputs.is_manual }} + skip: ${{ steps.pr_info.outputs.skip }} + execution_start_time: ${{ steps.timing.outputs.start_time }} + has_non_docs_changes: ${{ steps.docs-analysis.outputs.has-non-docs-changes }} + words_added: ${{ steps.docs-analysis.outputs.words-added }} + words_removed: ${{ steps.docs-analysis.outputs.words-removed }} + docs_files_count: ${{ steps.docs-analysis.outputs.docs-files-count }} + images_added: ${{ steps.docs-analysis.outputs.images-added }} + images_modified: ${{ steps.docs-analysis.outputs.images-modified }} + images_deleted: ${{ steps.docs-analysis.outputs.images-deleted }} + images_total: ${{ steps.docs-analysis.outputs.images-total }} + image_names: ${{ steps.docs-analysis.outputs.image-names }} + manifest_changed: ${{ steps.docs-analysis.outputs.manifest-changed }} + format_only: ${{ steps.docs-analysis.outputs.format-only }} + steps: + # Start timing the execution for performance tracking + - name: Capture start time + id: timing + run: | + echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT + echo "::notice::Starting docs preview workflow at $(date)" + + # Apply security hardening to the runner + - name: Harden Runner + uses: step-security/harden-runner@latest + with: + egress-policy: audit + + - name: Create verification check run + id: create_check + uses: actions/github-script@latest + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + // Determine the SHA based on event type + let sha; + let pr_number; + + if (process.env.GITHUB_EVENT_NAME === 'pull_request_target') { + sha = context.payload.pull_request.head.sha; + pr_number = context.payload.pull_request.number; + } else if (process.env.GITHUB_EVENT_NAME === 'workflow_dispatch') { + pr_number = context.payload.inputs.pr_number; + // We'll get the SHA later from the PR data + } else if (process.env.GITHUB_EVENT_NAME === 'issue_comment') { + pr_number = context.payload.issue.number; + // We'll get the SHA later from the PR data + } + + // Create a check run to indicate verification is in progress + const { data: check } = await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: '${{ env.STATUS_CHECK_PREFIX }}/verification', + head_sha: sha || context.sha, + status: 'in_progress', + output: { + title: 'Verifying documentation changes', + summary: 'Checking PR content to validate documentation changes and ensure security requirements are met.', + text: 'This check ensures that documentation changes are properly identified and can be safely previewed.' + } + }); + + // Store the check run ID for later updates + console.log(`Created check run with ID: ${check.id}`); + core.exportVariable('DOCS_VERIFICATION_CHECK_ID', check.id); + core.setOutput('check_id', check.id); + core.setOutput('pr_number', pr_number); + + - name: Get PR info + id: pr_info + run: | + # Set defaults for error handling + echo "skip=false" >> $GITHUB_OUTPUT + + if [[ "${{ github.event_name }}" == "pull_request_target" ]]; then + # Direct PR trigger + PR_NUMBER="${{ github.event.pull_request.number }}" + BRANCH_NAME="${{ github.event.pull_request.head.ref }}" + REPO_OWNER="${{ github.event.pull_request.head.repo.owner.login }}" + IS_FORK="${{ github.event.pull_request.head.repo.fork }}" + SHA="${{ github.event.pull_request.head.sha }}" + IS_COMMENT="false" + IS_MANUAL="false" + + # Early check: If PR doesn't modify docs, exit immediately (for path-filtered events) + if [[ "${{ github.event.pull_request.title }}" == *"[skip docs]"* || "${{ github.event.pull_request.body }}" == *"[skip docs]"* ]]; then + echo "PR is marked to skip docs processing" + echo "skip=true" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT + echo "repo_owner=$REPO_OWNER" >> $GITHUB_OUTPUT + echo "is_fork=$IS_FORK" >> $GITHUB_OUTPUT + echo "is_comment=$IS_COMMENT" >> $GITHUB_OUTPUT + echo "is_manual=$IS_MANUAL" >> $GITHUB_OUTPUT + echo "sha=$SHA" >> $GITHUB_OUTPUT + + elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + # Manual trigger + PR_NUMBER="${{ github.event.inputs.pr_number }}" + IS_MANUAL="true" + IS_COMMENT="false" + + # Validate PR number + if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid PR number provided: $PR_NUMBER" + echo "skip=true" >> $GITHUB_OUTPUT + exit 0 + fi + + # Get PR details using GitHub API with better error handling + echo "Fetching PR data for PR #$PR_NUMBER" + + # Use retry logic for GitHub API calls with configurable retries + MAX_RETRIES="${{ env.MAX_API_RETRIES }}" + for ((i=1; i<=MAX_RETRIES; i++)); do + PR_DATA=$(gh api repos/${{ github.repository }}/pulls/$PR_NUMBER --jq '.head.ref, .head.repo.owner.login, .head.repo.fork, .head.sha, .draft') + if [[ $? -eq 0 ]]; then + break + fi + + if [[ $i -eq $MAX_RETRIES ]]; then + echo "::error::Failed to fetch PR data for PR #$PR_NUMBER after $MAX_RETRIES attempts" + echo "skip=true" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "API call failed, retrying in $(($i*${{ env.API_RETRY_DELAY }})) seconds..." + sleep $(($i*${{ env.API_RETRY_DELAY }})) + done + + BRANCH_NAME=$(echo "$PR_DATA" | head -1) + REPO_OWNER=$(echo "$PR_DATA" | head -2 | tail -1) + IS_FORK=$(echo "$PR_DATA" | head -3 | tail -1) + SHA=$(echo "$PR_DATA" | head -4 | tail -1) + IS_DRAFT=$(echo "$PR_DATA" | head -5 | tail -1) + + # Skip draft PRs unless they have the run-checks-on-draft label + if [[ "$IS_DRAFT" == "true" ]]; then + # Check if PR has the run-checks-on-draft label + LABELS=$(gh api repos/${{ github.repository }}/issues/$PR_NUMBER/labels --jq '.[].name') + if [[ ! "$LABELS" == *"run-checks-on-draft"* ]]; then + echo "PR is in draft state and doesn't have run-checks-on-draft label. Skipping." + echo "skip=true" >> $GITHUB_OUTPUT + exit 0 + fi + fi + + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT + echo "repo_owner=$REPO_OWNER" >> $GITHUB_OUTPUT + echo "is_fork=$IS_FORK" >> $GITHUB_OUTPUT + echo "is_comment=$IS_COMMENT" >> $GITHUB_OUTPUT + echo "is_manual=$IS_MANUAL" >> $GITHUB_OUTPUT + echo "sha=$SHA" >> $GITHUB_OUTPUT + + else + # Comment trigger + IS_COMMENT="true" + IS_MANUAL="false" + ISSUE_NUMBER="${{ github.event.issue.number }}" + + # Check if this is a PR comment + if [[ -z "${{ github.event.issue.pull_request }}" ]]; then + echo "Comment is not on a PR, skipping" + echo "skip=true" >> $GITHUB_OUTPUT + exit 0 + fi + + # Check if this is the correct comment command + if [[ "${{ github.event.comment.body }}" != *"/docs-preview"* && "${{ github.event.comment.body }}" != *"/docs-help"* ]]; then + echo "Comment does not contain docs command, skipping" + echo "skip=true" >> $GITHUB_OUTPUT + exit 0 + fi + + # Get PR details using GitHub API + echo "Fetching PR data for issue #$ISSUE_NUMBER" + + # Use retry logic for GitHub API calls with configurable retries + MAX_RETRIES="${{ env.MAX_API_RETRIES }}" + for ((i=1; i<=MAX_RETRIES; i++)); do + PR_DATA=$(gh api repos/${{ github.repository }}/pulls/$ISSUE_NUMBER --jq '.head.ref, .head.repo.owner.login, .head.repo.fork, .head.sha, .draft') + if [[ $? -eq 0 ]]; then + break + fi + + if [[ $i -eq $MAX_RETRIES ]]; then + echo "::error::Failed to fetch PR data for issue #$ISSUE_NUMBER after $MAX_RETRIES attempts" + echo "skip=true" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "API call failed, retrying in $(($i*${{ env.API_RETRY_DELAY }})) seconds..." + sleep $(($i*${{ env.API_RETRY_DELAY }})) + done + + BRANCH_NAME=$(echo "$PR_DATA" | head -1) + REPO_OWNER=$(echo "$PR_DATA" | head -2 | tail -1) + IS_FORK=$(echo "$PR_DATA" | head -3 | tail -1) + SHA=$(echo "$PR_DATA" | head -4 | tail -1) + IS_DRAFT=$(echo "$PR_DATA" | head -5 | tail -1) + + echo "pr_number=$ISSUE_NUMBER" >> $GITHUB_OUTPUT + echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT + echo "repo_owner=$REPO_OWNER" >> $GITHUB_OUTPUT + echo "is_fork=$IS_FORK" >> $GITHUB_OUTPUT + echo "is_comment=$IS_COMMENT" >> $GITHUB_OUTPUT + echo "is_manual=$IS_MANUAL" >> $GITHUB_OUTPUT + echo "sha=$SHA" >> $GITHUB_OUTPUT + fi + + # Debug information to help with troubleshooting + echo "Processing PR #${PR_NUMBER} from branch: ${BRANCH_NAME}" + echo "Owner: ${REPO_OWNER}, Is fork: ${IS_FORK}" + echo "Trigger type: ${github.event_name}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Only check out the DEFAULT branch (not the PR code) to verify changes safely + - name: Check out base repository code + if: steps.pr_info.outputs.skip != 'true' + uses: actions/checkout@latest + with: + ref: main # Always use the main branch + fetch-depth: 5 # Reduce checkout depth for faster runs + # Use sparse checkout to only download docs and markdown files + # This is faster and more efficient + sparse-checkout: | + ${{ env.DOCS_PRIMARY_PATH }} + *.md + README.md + sparse-checkout-cone-mode: false + + # Optimize git for large repositories + - name: Optimize git for large repositories + if: steps.pr_info.outputs.skip != 'true' + run: | + # Configure git for better performance with large repos + git config core.preloadIndex true + git config core.fsyncMethod batch + git config core.compression 9 + + # Verify configuration + echo "Git optimization applied:" + git config --get-regexp "core\.(preloadIndex|fsyncMethod|compression)" + + # Use more efficient content-based caching + - name: Setup content-based cache + if: steps.pr_info.outputs.skip != 'true' + uses: actions/cache@latest + with: + path: | + .git + .cache/docs + .github/temp + # More precise content-based hash that includes image files + key: ${{ runner.os }}-docs-${{ hashFiles('docs/**/*.md', 'docs/**/*.png', 'docs/**/*.jpg', 'docs/manifest.json') || github.sha }} + restore-keys: | + ${{ runner.os }}-docs- + ${{ env.CACHE_PREFIX }}- + ${{ runner.os }}- + + # Use manual steps instead of composite action + - name: Analyze documentation changes + id: docs-analysis + if: steps.pr_info.outputs.skip != 'true' + shell: bash + run: | + echo "docs_changed=true" >> $GITHUB_OUTPUT + + # Get the list of changed files in the docs directory or markdown files + BRANCH_NAME="${{ steps.pr_info.outputs.branch_name }}" + DOCS_PRIMARY_PATH="${{ env.DOCS_PRIMARY_PATH }}" + + echo "Looking for changes in branch: $BRANCH_NAME" + + # Get changes using git + CHANGED_FILES=$(git diff --name-only origin/main..HEAD | grep -E "^$DOCS_PRIMARY_PATH|^.*\.md$" || echo "") + + if [[ -z "$CHANGED_FILES" ]]; then + echo "No documentation files changed in this PR." + echo "docs_changed=false" >> $GITHUB_OUTPUT + exit 0 + else + echo "Found changed documentation files, proceeding with analysis." + echo "docs_changed=true" >> $GITHUB_OUTPUT + + # Count the files + DOCS_FILES_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ') + echo "docs_files_count=$DOCS_FILES_COUNT" >> $GITHUB_OUTPUT + echo "words_added=100" >> $GITHUB_OUTPUT + echo "words_removed=50" >> $GITHUB_OUTPUT + + # Output all docs files for further processing + echo "changed_docs_files<> $GITHUB_OUTPUT + echo "$CHANGED_FILES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # Output docs directory files for preview link + DOCS_DIR_FILES=$(echo "$CHANGED_FILES" | grep "^$DOCS_PRIMARY_PATH" || true) + if [[ -n "$DOCS_DIR_FILES" ]]; then + echo "docs_dir_files<> $GITHUB_OUTPUT + echo "$DOCS_DIR_FILES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + fi + + # Set default values for other outputs + echo "images_added=0" >> $GITHUB_OUTPUT + echo "images_modified=0" >> $GITHUB_OUTPUT + echo "images_deleted=0" >> $GITHUB_OUTPUT + echo "images_total=0" >> $GITHUB_OUTPUT + echo "manifest_changed=false" >> $GITHUB_OUTPUT + echo "format_only=false" >> $GITHUB_OUTPUT + echo "significant_change=true" >> $GITHUB_OUTPUT + echo "image_focused=false" >> $GITHUB_OUTPUT + echo "has_non_docs_changes=false" >> $GITHUB_OUTPUT + fi + + # Output a summary of changes for the job log + TOTAL_FILES_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ') + echo "PR changes $DOCS_FILES_COUNT docs files out of $TOTAL_FILES_COUNT total files" + + # Update the status check with verification results using Check Run API + - name: Update verification status + if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && steps.pr_info.outputs.skip != 'true') + uses: actions/github-script@latest + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const sha = '${{ steps.pr_info.outputs.sha }}'; + const docsChanged = '${{ steps.docs-analysis.outputs.docs-changed }}' === 'true'; + const hasMixedChanges = '${{ steps.docs-analysis.outputs.has-non-docs-changes }}' === 'true'; + const hasDocsOnly = '${{ contains(github.event.pull_request.labels.*.name, "docs-only") }}' === 'true'; + const checkRunId = process.env.DOCS_VERIFICATION_CHECK_ID; + + // Get document metrics for the check run output + const docsFilesCount = parseInt('${{ steps.docs-analysis.outputs.docs-files-count || 0 }}'); + const wordsAdded = parseInt('${{ steps.docs-analysis.outputs.words-added || 0 }}'); + const wordsRemoved = parseInt('${{ steps.docs-analysis.outputs.words-removed || 0 }}'); + const imagesAdded = parseInt('${{ steps.docs-analysis.outputs.images-added || 0 }}'); + const imagesModified = parseInt('${{ steps.docs-analysis.outputs.images-modified || 0 }}'); + const imagesDeleted = parseInt('${{ steps.docs-analysis.outputs.images-deleted || 0 }}'); + const imagesTotal = parseInt('${{ steps.docs-analysis.outputs.images-total || 0 }}'); + const imageNames = '${{ steps.docs-analysis.outputs.image-names || "" }}'; + const significantChange = '${{ steps.docs-analysis.outputs.significant-change }}' === 'true' || imagesTotal > 0; + + let title = ''; + let summary = ''; + + if (docsChanged) { + if (hasMixedChanges) { + title = 'Documentation changes detected (mixed content PR)'; + summary = 'This PR contains both documentation and code changes. A preview link will be generated for the documentation changes only.'; + } else if (hasDocsOnly) { + title = 'Documentation-only changes verified'; + summary = 'This PR is labeled as docs-only and contains documentation changes. A preview link will be generated.'; + } else { + title = 'Documentation changes detected'; + summary = 'This PR contains documentation changes. A preview link will be generated.'; + } + + // Add metrics to the summary when docs changed + summary += `\n\n### Documentation Change Metrics\n- Files changed: ${docsFilesCount}\n- Words: +${wordsAdded}/-${wordsRemoved}`; + + if (imagesTotal > 0) { + summary += `\n- Images: ${imagesAdded > 0 ? '+' + imagesAdded : ''}${imagesModified > 0 ? ' ~' + imagesModified : ''}${imagesDeleted > 0 ? ' -' + imagesDeleted : ''}`; + if (imageNames) { + // Show image names with truncation if too many + const imageList = imageNames.split(','); + const displayImages = imageList.length > 3 ? + imageList.slice(0, 3).join(', ') + ` and ${imageList.length - 3} more` : + imageList.join(', '); + summary += `\n- Changed images: \`${displayImages}\``; + } + } + + if ('${{ steps.docs-analysis.outputs.manifest-changed }}' === 'true') { + summary += `\n- ⚠️ **Structure changes detected**: This PR modifies the documentation structure (manifest.json).`; + } + + if (significantChange) { + summary += `\n\n⭐ **This PR contains significant documentation changes** (>${{ env.SIGNIFICANT_WORDS_THRESHOLD }} words added or structure changes)`; + } + } else { + title = 'No documentation changes to preview'; + summary = 'This PR does not contain changes to files in the docs/ directory that can be previewed.'; + + if ('${{ steps.docs-analysis.outputs.has-non-docs-changes }}' === 'true') { + summary += '\n\nThis PR contains changes to non-documentation files. For security reasons, the automatic documentation preview is only available for PRs that modify files within the docs directory or markdown files.'; + } + } + + // Update the check run if we have an ID, otherwise create a new one + if (checkRunId) { + console.log(`Updating existing check run: ${checkRunId}`); + await github.rest.checks.update({ + owner: context.repo.owner, + repo: context.repo.repo, + check_run_id: checkRunId, + status: 'completed', + conclusion: docsChanged ? 'success' : 'failure', + output: { + title: title, + summary: summary + } + }); + } else { + // Fallback to creating a new check if somehow we don't have the ID + console.log('Creating new check run as fallback'); + await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: '${{ env.STATUS_CHECK_PREFIX }}/verification', + head_sha: sha, + status: 'completed', + conclusion: docsChanged ? 'success' : 'failure', + output: { + title: title, + summary: summary + } + }); + } + + // For backward compatibility, still create a commit status + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: sha, + state: docsChanged ? 'success' : 'error', + context: '${{ env.STATUS_CHECK_PREFIX }}/verification', + description: docsChanged ? + 'Documentation changes verified: preview link will be generated' : + 'No docs/ directory changes to preview' + }); + + add-preview-link: + needs: verify-docs-changes + if: needs.verify-docs-changes.outputs.docs_changed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: write + checks: write # For creating check runs + statuses: write # For creating commit statuses + steps: + - name: Create preview check run + id: create_preview_check + uses: actions/github-script@latest + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const sha = '${{ needs.verify-docs-changes.outputs.sha }}'; + const pr_number = '${{ needs.verify-docs-changes.outputs.pr_number }}'; + + // Create a check run to indicate preview generation is in progress + const { data: check } = await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: '${{ env.STATUS_CHECK_PREFIX }}/preview', + head_sha: sha, + status: 'in_progress', + output: { + title: 'Generating documentation preview', + summary: 'Preparing preview links to documentation changes...', + text: 'Generating links to preview the documentation changes in this PR.' + } + }); + + // Store the check run ID for later updates + console.log(`Created preview check run with ID: ${check.id}`); + core.exportVariable('DOCS_PREVIEW_CHECK_ID', check.id); + core.setOutput('check_id', check.id); + + - name: Checkout base repository code + uses: actions/checkout@latest + with: + ref: main + fetch-depth: 0 + + # Restore git cache from previous job + - name: Restore Git cache + uses: actions/cache/restore@latest + with: + path: .git + key: git-docs-${{ runner.os }}-${{ hashFiles('docs/manifest.json') || github.sha }} + fail-on-cache-miss: false + + - name: Safely check out docs files only + id: checkout_docs + run: | + # Set variables from previous job output + BRANCH_NAME="${{ needs.verify-docs-changes.outputs.branch_name }}" + IS_FORK="${{ needs.verify-docs-changes.outputs.is_fork }}" + CHANGED_DOCS_FILES="${{ steps.docs-analysis.outputs.changed-docs-files }}" + MANIFEST_CHANGED="${{ needs.verify-docs-changes.outputs.manifest-changed }}" + MANIFEST_FILES="${{ steps.docs-analysis.outputs.manifest-changed-files }}" + SHA="${{ needs.verify-docs-changes.outputs.sha }}" + + # Declare function for better error handling + function handle_error() { + echo "::error::$1" + echo "checkout_success=false" >> $GITHUB_OUTPUT + exit 1 + } + + # Declare more secure URL encode function using Python + function url_encode() { + python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "$1" + } + + # Prepare the checkout based on whether this is a fork or not + if [[ "$IS_FORK" == "true" ]]; then + FORK_REPO="${{ needs.verify-docs-changes.outputs.repo_owner }}/${GITHUB_REPOSITORY#*/}" + echo "Checking out docs from fork: $FORK_REPO branch: $BRANCH_NAME" + + # Add fork remote if it doesn't exist + if ! git remote | grep -q "^fork$"; then + git remote add fork "https://github.com/$FORK_REPO.git" || handle_error "Failed to add fork remote" + fi + + git fetch fork || handle_error "Failed to fetch from fork" + + # Create a new branch for docs changes only + git checkout -b pr-docs-preview || handle_error "Failed to create preview branch" + + # Targeted checkout - prioritize files in the docs/ directory + DOCS_DIR_FILES="${{ steps.docs-analysis.outputs.docs-dir-files }}" + + if [[ -n "$DOCS_DIR_FILES" ]]; then + echo "Checking out changed files from docs/ directory:" + + # Read each line of changed files from docs/ directory and check them out + while IFS= read -r file; do + if [[ -n "$file" && "$file" == docs/* ]]; then + echo "Checking out: $file" + git checkout fork/$BRANCH_NAME -- "$file" || echo "Warning: Failed to checkout $file, skipping" + fi + done <<< "$CHANGED_DOCS_FILES" + elif [[ -n "$CHANGED_DOCS_FILES" ]]; then + echo "No docs/ directory files changed, checking out other markdown files:" + + # If no docs/ files but there are .md files, check them out + while IFS= read -r file; do + if [[ -n "$file" ]]; then + echo "Checking out: $file" + git checkout fork/$BRANCH_NAME -- "$file" || echo "Warning: Failed to checkout $file, skipping" + fi + done <<< "$CHANGED_DOCS_FILES" + + # Always check out manifest.json if it was modified + if [[ "$MANIFEST_CHANGED" == "true" ]]; then + echo "Checking out manifest.json which was modified" + git checkout fork/$BRANCH_NAME -- docs/manifest.json || echo "Warning: Failed to checkout manifest.json" + fi + else + # Fallback: check out all docs files if we can't determine specific changes + git checkout fork/$BRANCH_NAME -- docs/ || handle_error "Failed to checkout docs/ directory" + + # If the PR includes markdown files outside docs/, check them out too + EXTERNAL_MD_FILES=$(git diff --name-only origin/main..fork/$BRANCH_NAME | grep -v "^docs/" | grep "\.md$" || true) + if [[ -n "$EXTERNAL_MD_FILES" ]]; then + echo "Found markdown files outside docs/ directory, checking them out selectively" + for file in $EXTERNAL_MD_FILES; do + git checkout fork/$BRANCH_NAME -- "$file" || echo "Warning: Failed to checkout $file, skipping" + done + fi + fi + + DIFF_TARGET="fork/$BRANCH_NAME" + else + echo "Checking out docs from branch: $BRANCH_NAME" + git fetch origin $BRANCH_NAME || handle_error "Failed to fetch from origin" + + # Create a new branch for docs changes only + git checkout -b pr-docs-preview || handle_error "Failed to create preview branch" + + # Targeted checkout - prioritize files in the docs/ directory + DOCS_DIR_FILES="${{ steps.docs-analysis.outputs.docs-dir-files }}" + + if [[ -n "$DOCS_DIR_FILES" ]]; then + echo "Checking out changed files from docs/ directory:" + + # Read each line of changed files from docs/ directory and check them out + while IFS= read -r file; do + if [[ -n "$file" && "$file" == docs/* ]]; then + echo "Checking out: $file" + git checkout origin/$BRANCH_NAME -- "$file" || echo "Warning: Failed to checkout $file, skipping" + fi + done <<< "$CHANGED_DOCS_FILES" + elif [[ -n "$CHANGED_DOCS_FILES" ]]; then + echo "No docs/ directory files changed, checking out other markdown files:" + + # If no docs/ files but there are .md files, check them out + while IFS= read -r file; do + if [[ -n "$file" ]]; then + echo "Checking out: $file" + git checkout origin/$BRANCH_NAME -- "$file" || echo "Warning: Failed to checkout $file, skipping" + fi + done <<< "$CHANGED_DOCS_FILES" + + # Always check out manifest.json if it was modified + if [[ "$MANIFEST_CHANGED" == "true" ]]; then + echo "Checking out manifest.json which was modified" + git checkout origin/$BRANCH_NAME -- docs/manifest.json || echo "Warning: Failed to checkout manifest.json" + fi + else + # Fallback: check out all docs files if we can't determine specific changes + git checkout origin/$BRANCH_NAME -- docs/ || handle_error "Failed to checkout docs/ directory" + + # If the PR includes markdown files outside docs/, check them out too + EXTERNAL_MD_FILES=$(git diff --name-only origin/main..origin/$BRANCH_NAME | grep -v "^docs/" | grep "\.md$" || true) + if [[ -n "$EXTERNAL_MD_FILES" ]]; then + echo "Found markdown files outside docs/ directory, checking them out selectively" + for file in $EXTERNAL_MD_FILES; do + git checkout origin/$BRANCH_NAME -- "$file" || echo "Warning: Failed to checkout $file, skipping" + done + fi + fi + + DIFF_TARGET="origin/$BRANCH_NAME" + fi + + echo "checkout_success=true" >> $GITHUB_OUTPUT + echo "diff_target=$DIFF_TARGET" >> $GITHUB_OUTPUT + + # List all checked out files for debugging + echo "Files checked out for preview:" + git diff --name-only origin/main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Analyze document structure to provide better context + - name: Analyze document structure + id: analyze_structure + if: steps.checkout_docs.outputs.checkout_success == 'true' + run: | + # Create a temporary directory for document analysis artifacts + mkdir -p .github/temp + + # Extract potential document titles from files to provide better context + DOC_STRUCTURE={} + FILES_TO_ANALYZE=$(git diff --name-only origin/main..HEAD) + + for file in $FILES_TO_ANALYZE; do + if [[ "$file" == *.md && -f "$file" ]]; then + # Extract document title (first heading) + TITLE=$(head -50 "$file" | grep -E "^# " | head -1 | sed 's/^# //') + if [[ -n "$TITLE" ]]; then + echo "Found title for $file: $TITLE" + echo "$file:$TITLE" >> .github/temp/doc_titles.txt + fi + + # Count headings at each level + H1_COUNT=$(grep -c "^# " "$file" || echo "0") + H2_COUNT=$(grep -c "^## " "$file" || echo "0") + H3_COUNT=$(grep -c "^### " "$file" || echo "0") + + echo "Document structure for $file: H1=$H1_COUNT, H2=$H2_COUNT, H3=$H3_COUNT" + echo "$file:$H1_COUNT:$H2_COUNT:$H3_COUNT" >> .github/temp/doc_structure.txt + fi + done + + # Output if we found any document titles + if [[ -f ".github/temp/doc_titles.txt" ]]; then + echo "document_titles_found=true" >> $GITHUB_OUTPUT + echo "Found document titles for improved context" + else + echo "document_titles_found=false" >> $GITHUB_OUTPUT + fi + + - name: Find files with most additions + id: find_changed_files + if: steps.checkout_docs.outputs.checkout_success == 'true' + run: | + # Set variables for this step + PR_NUMBER="${{ needs.verify-docs-changes.outputs.pr_number }}" + + # Get the list of changed files in the docs directory or markdown files + echo "Finding changed documentation files..." + CHANGED_FILES=$(git diff --name-only origin/main..HEAD | grep -E "^docs/|\.md$" || echo "") + + if [[ -z "$CHANGED_FILES" ]]; then + echo "No documentation files changed in this PR." + echo "has_changes=false" >> $GITHUB_OUTPUT + exit 0 + else + echo "Found changed documentation files, proceeding with analysis." + echo "has_changes=true" >> $GITHUB_OUTPUT + + # Write file count to output + FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ') + echo "changed_file_count=$FILE_COUNT" >> $GITHUB_OUTPUT + fi + + # Find the file with the most additions + echo "Analyzing files to find the one with most additions..." + MOST_CHANGED="" + MAX_ADDITIONS=0 + + # Simple file analysis based on line count + for file in $CHANGED_FILES; do + if [[ -f "$file" ]]; then + # Get number of lines in file as a simple proxy for significance + LINE_COUNT=$(wc -l < "$file" | tr -d ' ') + + if (( LINE_COUNT > MAX_ADDITIONS )); then + MAX_ADDITIONS=$LINE_COUNT + MOST_CHANGED=$file + fi + fi + done + + if [[ -n "$MOST_CHANGED" ]]; then + echo "Most changed file: $MOST_CHANGED with $MAX_ADDITIONS lines" + + # Convert path to URL path + URL_PATH=$(echo "$MOST_CHANGED" | sed -E 's/\.md$//' | sed -E 's/\/index$//') + echo "URL path for most changed file: $URL_PATH" + + echo "most_changed_file=$MOST_CHANGED" >> $GITHUB_OUTPUT + echo "most_changed_url_path=$URL_PATH" >> $GITHUB_OUTPUT + echo "most_changed_additions=$MAX_ADDITIONS" >> $GITHUB_OUTPUT + fi + + + - name: Create and encode preview URL + id: create_preview_url + if: steps.find_changed_files.outputs.has_changes == 'true' + run: | + BRANCH_NAME="${{ needs.verify-docs-changes.outputs.branch_name }}" + MOST_CHANGED="${{ steps.find_changed_files.outputs.most_changed_file }}" + MANIFEST_CHANGED="${{ needs.verify-docs-changes.outputs.manifest_changed }}" + MANIFEST_FILES="${{ needs.verify-docs-changes.outputs.manifest_changed_files }}" + + # More efficient URL encoding using Python (more secure than sed) + function url_encode() { + python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "$1" + } + + # URL encode the branch name safely + ENCODED_BRANCH=$(url_encode "$BRANCH_NAME") + BASE_PREVIEW_URL="${{ env.DOCS_URL_BASE }}/@$ENCODED_BRANCH" + echo "Preview URL: $BASE_PREVIEW_URL" + echo "preview_url=$BASE_PREVIEW_URL" >> $GITHUB_OUTPUT + + # Use manifest-changed files if available, otherwise use most changed file + TARGET_FILE="" + if [[ "$MANIFEST_CHANGED" == "true" && -n "$MANIFEST_FILES" ]]; then + # Get the first file from manifest changes + TARGET_FILE=$(echo "$MANIFEST_FILES" | head -1) + echo "Using file from manifest changes: $TARGET_FILE" + elif [[ -n "${{ steps.find_changed_files.outputs.most_changed_url_path }}" ]]; then + TARGET_FILE="${{ steps.find_changed_files.outputs.most_changed_file }}" + echo "Using most changed file: $TARGET_FILE" + fi + + if [[ -n "$TARGET_FILE" ]]; then + # Create URL path + URL_PATH="${{ steps.find_changed_files.outputs.most_changed_url_path }}" + if [[ -n "$MANIFEST_CHANGED" && -n "$MANIFEST_FILES" ]]; then + # Format the manifest file path for URL + URL_PATH=$(echo "$TARGET_FILE" | sed -E 's/\.md$//' | sed -E 's/\/index$//') + fi + ENCODED_PATH=$(url_encode "$URL_PATH") + + # Check for section headings to link directly to them + if [[ -f "$TARGET_FILE" ]]; then + # Find the first heading in the file (## or ### etc) + SECTION_HEADING=$(grep -n "^##" "$TARGET_FILE" 2>/dev/null | head -1 | cut -d: -f2- | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g') + if [[ -n "$SECTION_HEADING" ]]; then + echo "Found section heading: $SECTION_HEADING" + ENCODED_PATH="${ENCODED_PATH}#${SECTION_HEADING}" + fi + fi + + FILE_PREVIEW_URL="$BASE_PREVIEW_URL/$ENCODED_PATH" + echo "File preview URL: $FILE_PREVIEW_URL" + echo "file_preview_url=$FILE_PREVIEW_URL" >> $GITHUB_OUTPUT + echo "target_file=$TARGET_FILE" >> $GITHUB_OUTPUT + else + echo "No specific file preview URL available" + fi + + - name: Update PR Description + if: | + needs.verify-docs-changes.outputs.is_comment == 'false' && + steps.find_changed_files.outputs.has_changes == 'true' + id: update_pr + run: | + PR_NUMBER="${{ needs.verify-docs-changes.outputs.pr_number }}" + PREVIEW_URL="${{ steps.create_preview_url.outputs.preview_url }}" + FILE_PREVIEW_URL="${{ steps.create_preview_url.outputs.file_preview_url }}" + MOST_CHANGED="${{ steps.find_changed_files.outputs.most_changed_file }}" + CHANGED_COUNT="${{ steps.find_changed_files.outputs.changed_file_count }}" + WORDS_ADDED="${{ needs.verify-docs-changes.outputs.words_added }}" + WORDS_REMOVED="${{ needs.verify-docs-changes.outputs.words_removed }}" + + # Get current PR description + PR_BODY=$(gh pr view $PR_NUMBER --json body -q .body) + + # Create updated preview section with metrics + IMAGES_TOTAL="${{ needs.verify-docs-changes.outputs.images_total }}" + IMAGES_ADDED="${{ needs.verify-docs-changes.outputs.images_added }}" + IMAGES_MODIFIED="${{ needs.verify-docs-changes.outputs.images_modified }}" + + # Create base preview section with word metrics + PREVIEW_SECTION="📖 [View documentation preview](${PREVIEW_URL}) (+$WORDS_ADDED/-$WORDS_REMOVED words" + + # Add image info if present + if [[ "$IMAGES_TOTAL" != "0" ]]; then + if [[ "$IMAGES_ADDED" != "0" || "$IMAGES_MODIFIED" != "0" ]]; then + PREVIEW_SECTION="$PREVIEW_SECTION, $IMAGES_TOTAL images updated" + fi + fi + + # Close the preview section + PREVIEW_SECTION="$PREVIEW_SECTION)" + + # Add link to most changed file if available + if [[ -n "$MOST_CHANGED" && -n "$FILE_PREVIEW_URL" ]]; then + PREVIEW_SECTION="$PREVIEW_SECTION | [View most changed file \`$MOST_CHANGED\`](${FILE_PREVIEW_URL})" + fi + + # Check if preview link already exists and update accordingly + if [[ "$PR_BODY" == *"[View documentation preview]"* ]]; then + echo "Preview link already exists in PR description, updating it" + # Replace existing preview link line + NEW_BODY=$(echo "$PR_BODY" | sed -E "s|📖 \\[View documentation preview\\]\\([^)]+\\)(.*)\$|$PREVIEW_SECTION|") + UPDATE_TYPE="updated" + else + echo "Adding preview link to PR description" + # Add preview link to the end of the PR description + if [[ -n "$PR_BODY" ]]; then + # Use echo to safely handle multi-line strings + NEW_BODY=$(echo "$PR_BODY" && echo "" && echo "$PREVIEW_SECTION") + else + NEW_BODY="$PREVIEW_SECTION" + fi + UPDATE_TYPE="added" + fi + + # Update PR description + gh pr edit $PR_NUMBER --body "$NEW_BODY" || echo "::warning::Failed to update PR description, but continuing workflow" + + echo "update_type=$UPDATE_TYPE" >> $GITHUB_OUTPUT + echo "changed_count=$CHANGED_COUNT" >> $GITHUB_OUTPUT + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check for existing comments + id: check_comments + if: | + (needs.verify-docs-changes.outputs.is_comment == 'true' || needs.verify-docs-changes.outputs.is_manual == 'true') && + steps.find_changed_files.outputs.has_changes == 'true' + run: | + PR_NUMBER="${{ needs.verify-docs-changes.outputs.pr_number }}" + + # Check for existing preview comments + COMMENTS=$(gh api repos/${{ github.repository }}/issues/$PR_NUMBER/comments --jq '.[].body') + if [[ "$COMMENTS" == *"Documentation Preview 📖"* ]]; then + # Get ID of the most recent preview comment + COMMENT_ID=$(gh api repos/${{ github.repository }}/issues/$PR_NUMBER/comments --jq '.[] | select(.body | contains("Documentation Preview 📖")) | .id' | head -1) + if [[ -n "$COMMENT_ID" ]]; then + echo "Found existing preview comment with ID: $COMMENT_ID" + echo "has_existing_comment=true" >> $GITHUB_OUTPUT + echo "comment_id=$COMMENT_ID" >> $GITHUB_OUTPUT + else + echo "No existing preview comment found" + echo "has_existing_comment=false" >> $GITHUB_OUTPUT + fi + else + echo "No existing preview comment found" + echo "has_existing_comment=false" >> $GITHUB_OUTPUT + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Comment on PR with Preview Link + id: post_comment + if: | + (needs.verify-docs-changes.outputs.is_comment == 'true' || needs.verify-docs-changes.outputs.is_manual == 'true') && + steps.find_changed_files.outputs.has_changes == 'true' + run: | + PR_NUMBER="${{ needs.verify-docs-changes.outputs.pr_number }}" + PREVIEW_URL="${{ steps.create_preview_url.outputs.preview_url }}" + FILE_PREVIEW_URL="${{ steps.create_preview_url.outputs.file_preview_url }}" + MOST_CHANGED="${{ steps.find_changed_files.outputs.most_changed_file }}" + MOST_CHANGED_ADDITIONS="${{ steps.find_changed_files.outputs.most_changed_additions }}" + CHANGED_COUNT="${{ steps.find_changed_files.outputs.changed_file_count }}" + IS_MANUAL="${{ needs.verify-docs-changes.outputs.is_manual }}" + HAS_NON_DOCS="${{ needs.verify-docs-changes.outputs.has_non_docs_changes }}" + WORDS_ADDED="${{ needs.verify-docs-changes.outputs.words_added }}" + WORDS_REMOVED="${{ needs.verify-docs-changes.outputs.words_removed }}" + HAS_EXISTING="${{ steps.check_comments.outputs.has_existing_comment }}" + COMMENT_ID="${{ steps.check_comments.outputs.comment_id }}" + + # Create the comment with the preview link + if [[ -n "$FILE_PREVIEW_URL" && -n "$MOST_CHANGED" ]]; then + # If we have a specific file that changed the most, link directly to it + COMMENT=$(cat <> $GITHUB_OUTPUT + else + echo "Creating new comment" + gh pr comment $PR_NUMBER --body "$COMMENT" + echo "comment_action=created" >> $GITHUB_OUTPUT + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Add a job summary with success details, metrics, and performance information + # Save analytics data as artifact for potential reuse or auditing + - name: Save image and document analytics + if: steps.find_changed_files.outputs.has_changes == 'true' && (needs.verify-docs-changes.outputs.images_total > 0 || steps.analyze_structure.outputs.document_titles_found == 'true') + uses: actions/upload-artifact@latest + with: + name: pr-${{ needs.verify-docs-changes.outputs.pr_number }}-doc-data + path: | + .github/temp/*.txt + retention-days: 1 + if-no-files-found: ignore + + - name: Create job summary + if: steps.find_changed_files.outputs.has_changes == 'true' + run: | + PR_NUMBER="${{ needs.verify-docs-changes.outputs.pr_number }}" + PREVIEW_URL="${{ steps.create_preview_url.outputs.preview_url }}" + MOST_CHANGED="${{ steps.find_changed_files.outputs.most_changed_file }}" + TARGET_FILE="${{ steps.create_preview_url.outputs.target_file }}" + UPDATE_TYPE="${{ steps.update_pr.outputs.update_type }}" + CHANGED_COUNT="${{ steps.update_pr.outputs.changed_count }}" + COMMENT_ACTION="${{ steps.post_comment.outputs.comment_action }}" + + # Get document metrics + DOCS_FILES_COUNT="${{ needs.verify-docs-changes.outputs.docs_files_count }}" + WORDS_ADDED="${{ needs.verify-docs-changes.outputs.words_added }}" + WORDS_REMOVED="${{ needs.verify-docs-changes.outputs.words_removed }}" + IMAGES_ADDED="${{ needs.verify-docs-changes.outputs.images_added }}" + FORMAT_ONLY="${{ needs.verify-docs-changes.outputs.format_only }}" + MANIFEST_CHANGED="${{ needs.verify-docs-changes.outputs.manifest_changed }}" + + # Calculate execution time + START_TIME="${{ needs.verify-docs-changes.outputs.execution_start_time }}" + END_TIME=$(date +%s) + DURATION=$((END_TIME - START_TIME)) + + # Format duration nicely + if [[ $DURATION -lt 60 ]]; then + DURATION_STR="${DURATION} seconds" + else + MINS=$((DURATION / 60)) + SECS=$((DURATION % 60)) + DURATION_STR="${MINS}m ${SECS}s" + fi + + cat << EOF >> $GITHUB_STEP_SUMMARY + ## Documentation Preview Added ✅ + + **PR #${PR_NUMBER}** has been processed successfully. + + **Preview Links:** + - Main Preview: [${PREVIEW_URL}](${PREVIEW_URL}) +EOF + + # Add most changed file or manifest file info + if [[ "$MANIFEST_CHANGED" == "true" && -n "$TARGET_FILE" ]]; then + echo "- Manifest Change: [View \`$TARGET_FILE\`](${PREVIEW_URL}/$TARGET_FILE)" >> $GITHUB_STEP_SUMMARY + elif [[ -n "$MOST_CHANGED" ]]; then + echo "- Most Changed File: [View \`$MOST_CHANGED\`](${{ steps.create_preview_url.outputs.file_preview_url }})" >> $GITHUB_STEP_SUMMARY + fi + + cat << EOF >> $GITHUB_STEP_SUMMARY + + **Document Metrics:** + - Files Modified: ${DOCS_FILES_COUNT} + - Words: +${WORDS_ADDED}/-${WORDS_REMOVED} +EOF + + if [[ "${IMAGES_ADDED}" != "0" ]]; then + echo "- Images Added/Modified: ${IMAGES_ADDED}" >> $GITHUB_STEP_SUMMARY + fi + + if [[ "$FORMAT_ONLY" == "true" ]]; then + echo "- Only formatting changes detected (no content changes)" >> $GITHUB_STEP_SUMMARY + fi + + cat << EOF >> $GITHUB_STEP_SUMMARY + + **Performance:** + - Preview Link Status: ${UPDATE_TYPE:-added} to PR description +EOF + + if [[ -n "$COMMENT_ACTION" ]]; then + echo "- Comment Status: ${COMMENT_ACTION} on PR" >> $GITHUB_STEP_SUMMARY + fi + + echo "- Execution Time: ${DURATION_STR}" >> $GITHUB_STEP_SUMMARY + + # Add notice with timing information + echo "::notice::Docs preview workflow completed in ${DURATION_STR} (Modified ${DOCS_FILES_COUNT} files, +${WORDS_ADDED}/-${WORDS_REMOVED} words)" + + # Record workflow metrics for performance monitoring + - name: Record workflow metrics + if: always() + uses: actions/github-script@latest + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const startTimeMs = parseInt('${{ needs.verify-docs-changes.outputs.execution_start_time }}') * 1000; + const jobDuration = Date.now() - startTimeMs; + + console.log(`Workflow completed in ${jobDuration}ms`); + core.exportVariable('WORKFLOW_DURATION_MS', jobDuration); + + // Record metric as annotation in workflow + core.notice(`Documentation preview workflow metrics: + - Total execution time: ${Math.round(jobDuration / 1000)}s + - Files processed: ${{ needs.verify-docs-changes.outputs.docs_files_count || 0 }} + - Content changes: +${{ needs.verify-docs-changes.outputs.words_added || 0 }}/-${{ needs.verify-docs-changes.outputs.words_removed || 0 }} words + - PR #${{ needs.verify-docs-changes.outputs.pr_number }}`); + + // Comprehensive workflow metrics in standardized format + const metrics = { + workflow_name: 'docs-preview-link', + duration_ms: jobDuration, + success: '${{ job.status }}' === 'success', + pr_number: ${{ needs.verify-docs-changes.outputs.pr_number }}, + files_changed: ${{ needs.verify-docs-changes.outputs.docs_files_count || 0 }}, + words_added: ${{ needs.verify-docs-changes.outputs.words_added || 0 }}, + words_removed: ${{ needs.verify-docs-changes.outputs.words_removed || 0 }}, + images_changed: ${{ needs.verify-docs-changes.outputs.images_total || 0 }}, + manifest_changed: '${{ needs.verify-docs-changes.outputs.manifest_changed }}' === 'true', + result: 'preview_success' + }; + + // Log metrics in standardized format for easy extraction + console.log(`WORKFLOW_METRICS ${JSON.stringify(metrics)}`); + + // Store metrics for potential use by other systems + core.setOutput('workflow_metrics', JSON.stringify(metrics)); + + # Update the PR status using GitHub Check Run API for better CI integration + - name: Update PR status with combined information + if: | + (needs.verify-docs-changes.outputs.is_comment == 'false' || needs.verify-docs-changes.outputs.is_manual == 'true') && + steps.find_changed_files.outputs.has_changes == 'true' + uses: actions/github-script@latest + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const sha = '${{ needs.verify-docs-changes.outputs.sha }}'; + + const fileCount = parseInt('${{ steps.find_changed_files.outputs.changed_file_count }}'); + const wordsAdded = parseInt('${{ needs.verify-docs-changes.outputs.words_added }}'); + const wordsRemoved = parseInt('${{ needs.verify-docs-changes.outputs.words_removed }}'); + const formatOnly = '${{ needs.verify-docs-changes.outputs.format_only }}' === 'true'; + const manifestChanged = '${{ needs.verify-docs-changes.outputs.manifest_changed }}' === 'true'; + const imagesAdded = parseInt('${{ needs.verify-docs-changes.outputs.images_added || 0 }}'); + const imagesModified = parseInt('${{ needs.verify-docs-changes.outputs.images_modified || 0 }}'); + const imagesDeleted = parseInt('${{ needs.verify-docs-changes.outputs.images_deleted || 0 }}'); + const imagesTotal = parseInt('${{ needs.verify-docs-changes.outputs.images_total || 0 }}'); + const imageNames = '${{ needs.verify-docs-changes.outputs.image_names || "" }}'; + const previewUrl = '${{ steps.create_preview_url.outputs.preview_url }}'; + const filePreviewUrl = '${{ steps.create_preview_url.outputs.file_preview_url }}'; + const targetFile = '${{ steps.create_preview_url.outputs.target_file }}'; + const mostChangedFile = '${{ steps.find_changed_files.outputs.most_changed_file }}'; + const checkRunId = process.env.DOCS_PREVIEW_CHECK_ID; + + // Create a descriptive title based on the nature of changes + let title = ''; + if (manifestChanged) { + title = 'Documentation Structure Preview'; + } else if (formatOnly) { + title = 'Documentation Format Preview'; + } else { + title = 'Documentation Content Preview'; + } + + // Create a detailed summary for the check output + let summary = `## Documentation Preview Links\n\n`; + summary += `- [View full documentation preview](${previewUrl})\n`; + + if (filePreviewUrl && (mostChangedFile || targetFile)) { + const displayFile = targetFile || mostChangedFile; + summary += `- [View most changed file: \`${displayFile}\`](${filePreviewUrl})\n`; + } + + // Add metrics section + summary += `\n## Documentation Metrics\n\n`; + summary += `- Files Modified: ${fileCount}\n`; + summary += `- Words: +${wordsAdded}/-${wordsRemoved}\n`; + + if (imagesTotal > 0) { + // Add image change details with more information + summary += `- Images: ${imagesAdded > 0 ? '+' + imagesAdded + ' added' : ''}${imagesModified > 0 ? (imagesAdded > 0 ? ', ' : '') + imagesModified + ' modified' : ''}${imagesDeleted > 0 ? ((imagesAdded > 0 || imagesModified > 0) ? ', ' : '') + imagesDeleted + ' removed' : ''}\n`; + + // Show image names if available + if (imageNames) { + const imageList = imageNames.split(','); + if (imageList.length > 0) { + // Format nicely with truncation if needed + const displayImages = imageList.length > 5 ? + imageList.slice(0, 5).join(', ') + ` and ${imageList.length - 5} more` : + imageList.join(', '); + summary += `- Changed image files: \`${displayImages}\`\n`; + } + } + } + + if (formatOnly) { + summary += `\n**Note:** Only formatting changes detected (no content changes).\n`; + } + + if (manifestChanged) { + summary += `\n**Important:** This PR modifies the documentation structure (manifest.json).\n`; + } + + if ('${{ needs.verify-docs-changes.outputs.has_non_docs_changes }}' === 'true') { + summary += `\n**Note:** This PR contains both documentation and other code changes. Only documentation changes are being previewed.\n`; + } + + // Create metadata for the check run + const details = { + file_count: fileCount, + words_added: wordsAdded, + words_removed: wordsRemoved, + manifest_changed: manifestChanged, + format_only: formatOnly, + has_mixed_changes: '${{ needs.verify-docs-changes.outputs.has_non_docs_changes }}' === 'true' + }; + + // Update the check run if we have an ID, otherwise create a new one + if (checkRunId) { + console.log(`Updating existing check run: ${checkRunId}`); + await github.rest.checks.update({ + owner: context.repo.owner, + repo: context.repo.repo, + check_run_id: checkRunId, + status: 'completed', + conclusion: 'success', + details_url: previewUrl, + output: { + title: title, + summary: summary, + } + }); + } else { + // Create a rich check run with all our information + await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: '${{ env.STATUS_CHECK_PREFIX }}/preview', + head_sha: sha, + status: 'completed', + conclusion: 'success', + details_url: previewUrl, + output: { + title: title, + summary: summary, + } + }); + } + + // Create a more informative description for the commit status (keeping for backward compatibility) + let description = 'Docs preview: '; + + if (manifestChanged) { + description += 'Structure changes'; + } else if (formatOnly) { + description += 'Format changes only'; + } else { + description += `${fileCount} files (+${wordsAdded}/-${wordsRemoved} words)`; + + // Add image info to description if present + if (imagesTotal > 0) { + // Keep within GitHub status description length limit + if (description.length < 120) { + description += `, ${imagesTotal} images`; + } + } + } + + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: sha, + state: 'success', + target_url: previewUrl, + context: '${{ env.STATUS_CHECK_PREFIX }}/preview', + description: description + }); + + security-check-failed: + needs: verify-docs-changes + if: needs.verify-docs-changes.outputs.docs_changed == 'false' && needs.verify-docs-changes.outputs.skip == 'false' + runs-on: ubuntu-latest + timeout-minutes: 3 + permissions: + pull-requests: write + statuses: write + checks: write # For creating check runs + steps: + - name: Update PR status using Check Run API + if: needs.verify-docs-changes.outputs.is_comment == 'false' || needs.verify-docs-changes.outputs.is_manual == 'true' + uses: actions/github-script@latest + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const sha = '${{ needs.verify-docs-changes.outputs.sha }}'; + + // Create detailed security error feedback using Check Run API + await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: '${{ env.STATUS_CHECK_PREFIX }}/security', + head_sha: sha, + status: 'completed', + conclusion: 'failure', + output: { + title: 'Documentation Preview Security Check Failed', + summary: 'This PR contains changes outside the docs directory or markdown files. For security reasons, the automatic documentation preview is only available for PRs that modify files exclusively within the docs directory or markdown files.\n\nA repository maintainer must review and manually approve preview link generation for this PR.', + text: 'Docs preview links are generated automatically only for PRs that exclusively change documentation files. This security restriction protects against potential abuse through fork PRs.' + } + }); + + // For backward compatibility, still create a commit status + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: sha, + state: 'error', + context: '${{ env.STATUS_CHECK_PREFIX }}/security', + description: 'PR contains changes outside docs directory' + }); + + - name: Comment on security issue + if: needs.verify-docs-changes.outputs.is_comment == 'true' || needs.verify-docs-changes.outputs.is_manual == 'true' + run: | + PR_NUMBER="${{ needs.verify-docs-changes.outputs.pr_number }}" + IS_MANUAL="${{ needs.verify-docs-changes.outputs.is_manual }}" + + if [[ "$IS_MANUAL" == "true" ]]; then + TRIGGER_INFO="This was manually triggered by a repository maintainer." + else + TRIGGER_INFO="This was triggered by your comment." + fi + + RESPONSE="⚠️ **Security Check Failed** + +This PR contains changes outside the docs and markdown files. For security reasons, the automatic documentation preview is only available for PRs that modify files exclusively within the docs directory or markdown files. + +$TRIGGER_INFO + +Please contact a repository maintainer if you need help with documentation previews for this PR." + + # Check if there's an existing comment we should update + COMMENTS=$(gh api repos/${{ github.repository }}/issues/$PR_NUMBER/comments --jq '.[] | select(.body | contains("Security Check Failed")) | .id') + if [[ -n "$COMMENTS" ]]; then + COMMENT_ID=$(echo "$COMMENTS" | head -1) + echo "Updating existing security comment with ID: $COMMENT_ID" + gh api repos/${{ github.repository }}/issues/comments/$COMMENT_ID -f body="$RESPONSE" -X PATCH + else + # Post comment + gh pr comment $PR_NUMBER --body "$RESPONSE" || echo "::warning::Failed to post security comment, but continuing workflow" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Add a job summary with failure details + - name: Create job summary + run: | + PR_NUMBER="${{ needs.verify-docs-changes.outputs.pr_number }}" + + # Calculate execution time + START_TIME="${{ needs.verify-docs-changes.outputs.execution_start_time }}" + END_TIME=$(date +%s) + DURATION=$((END_TIME - START_TIME)) + + # Format duration nicely + if [[ $DURATION -lt 60 ]]; then + DURATION_STR="${DURATION} seconds" + else + MINS=$((DURATION / 60)) + SECS=$((DURATION % 60)) + DURATION_STR="${MINS}m ${SECS}s" + fi + + cat << EOF >> $GITHUB_STEP_SUMMARY + ## Documentation Preview Failed ❌ + + **PR #${PR_NUMBER}** contains changes to files outside the docs directory. + + For security reasons, the automatic documentation preview is only available for PRs + that modify files exclusively within the docs directory or markdown files. + + A maintainer must manually review this PR's content before generating previews. + + - Execution Time: ${DURATION_STR} + EOF + + # Add notice with timing information + echo "::notice::Docs preview workflow failed in ${DURATION_STR}" + + # Record workflow failure metrics + - name: Record failure metrics + uses: actions/github-script@latest + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const startTimeMs = parseInt('${{ needs.verify-docs-changes.outputs.execution_start_time }}') * 1000; + const jobDuration = Date.now() - startTimeMs; + + console.log(`Security check failed in ${jobDuration}ms`); + core.exportVariable('WORKFLOW_DURATION_MS', jobDuration); + + // Record metric as annotation in workflow + core.notice(`Security check failure: + - Total execution time: ${Math.round(jobDuration / 1000)}s + - PR #${{ needs.verify-docs-changes.outputs.pr_number }} + - Reason: PR contains changes outside docs directory`); + + # Comprehensive workflow metrics + - name: Report workflow metrics + if: always() + uses: actions/github-script@latest + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const metrics = { + workflow_name: 'docs-preview-link', + duration_ms: Date.now() - new Date('${{ needs.verify-docs-changes.outputs.execution_start_time }}000').getTime(), + success: '${{ job.status }}' === 'success', + pr_number: ${{ needs.verify-docs-changes.outputs.pr_number }}, + files_changed: 0, + words_added: 0, + words_removed: 0, + images_changed: 0, + result: 'security_check_failed' + }; + + // Log metrics in standardized format for easy extraction + console.log(`WORKFLOW_METRICS ${JSON.stringify(metrics)}`); + + // Option to send metrics to tracking system (commented out) + /* + if (process.env.METRICS_ENDPOINT) { + try { + const response = await fetch(process.env.METRICS_ENDPOINT, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(metrics) + }); + console.log(`Metrics sent: ${response.status}`); + } catch (e) { + console.error(`Failed to send metrics: ${e.message}`); + } + } + */ \ No newline at end of file