Link checker - PR changed files #1351
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Link checker - PR changed files | |
| # Avoid collisions by ensuring only one run per ref | |
| concurrency: | |
| group: linkcheck-pr-${{ github.ref_name }} | |
| cancel-in-progress: false | |
| on: | |
| workflow_dispatch: | |
| deployment_status: | |
| pull_request: | |
| types: [opened, synchronize, reopened] | |
| # No paths filter - we check paths inside the job for fork PRs only | |
| # This prevents "Skipped" status for same-repo PRs | |
| permissions: | |
| contents: read | |
| deployments: read | |
| pull-requests: write | |
| jobs: | |
| linkChecker: | |
| runs-on: ubuntu-latest | |
| # Run on: | |
| # 1. Manual trigger (workflow_dispatch) | |
| # 2. Successful Mintlify deployment (for same-repo PRs - waits for preview URL) | |
| # 3. PR events from forks only (no Mintlify preview, checks against production) | |
| if: | | |
| github.event_name == 'workflow_dispatch' || | |
| (github.event_name == 'deployment_status' && | |
| github.event.deployment_status.state == 'success' && | |
| github.event.deployment.environment == 'staging' && | |
| contains(github.event.deployment_status.creator.login, 'mintlify') && | |
| contains(github.event.deployment_status.environment_url, 'mintlify')) || | |
| (github.event_name == 'pull_request' && | |
| github.event.pull_request.head.repo.fork == true) | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| # Needed to diff base..head for the associated PR | |
| fetch-depth: 0 | |
| - name: Early exit if no docs changed (fork PRs only) | |
| if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true | |
| id: fork-docs-check | |
| uses: tj-actions/[email protected] | |
| with: | |
| files: | | |
| **/*.md | |
| **/*.mdx | |
| - name: Report no changes (fork PRs only) | |
| if: | | |
| github.event_name == 'pull_request' && | |
| github.event.pull_request.head.repo.fork == true && | |
| steps.fork-docs-check.outputs.any_changed != 'true' | |
| run: | | |
| echo "✓ No documentation files (.md/.mdx) changed in this fork PR." | |
| echo "Link check not needed - exiting successfully." | |
| - name: Resolve PR and deployment URL | |
| id: pr-context | |
| if: github.event_name == 'deployment_status' | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const sha = context.payload.deployment?.sha; | |
| const deployUrl = | |
| context.payload.deployment_status?.environment_url || | |
| context.payload.deployment_status?.target_url || | |
| ''; | |
| core.info(`Deployment SHA: ${sha}`); | |
| core.info(`Deployment URL: ${deployUrl}`); | |
| // Find PR(s) associated with this deployment commit SHA | |
| const prsResp = await github.rest.repos.listPullRequestsAssociatedWithCommit({ | |
| owner, | |
| repo, | |
| commit_sha: sha, | |
| }); | |
| const pr = prsResp.data?.[0]; | |
| if (!pr) { | |
| core.warning(`No PR associated with commit ${sha}. Skipping linkcheck + PR comment.`); | |
| core.setOutput('pr_number', ''); | |
| core.setOutput('pr_closed', 'false'); | |
| core.setOutput('deploy_url', deployUrl); | |
| return; | |
| } | |
| // Check if PR is already merged/closed - preview likely torn down | |
| if (pr.state === 'closed') { | |
| core.warning(`PR #${pr.number} is already ${pr.merged_at ? 'merged' : 'closed'}. Preview likely torn down. Skipping link check.`); | |
| core.setOutput('pr_number', ''); | |
| core.setOutput('pr_closed', 'true'); | |
| core.setOutput('deploy_url', ''); | |
| return; | |
| } | |
| core.info(`Associated PR: #${pr.number} (${pr.html_url})`); | |
| core.setOutput('pr_number', String(pr.number)); | |
| core.setOutput('pr_closed', 'false'); | |
| core.setOutput('base_sha', pr.base.sha); | |
| core.setOutput('head_sha', pr.head.sha); | |
| core.setOutput('deploy_url', deployUrl); | |
| - name: Get changed documentation files | |
| id: changed-files | |
| # Skip this for fork PRs with no doc changes (already checked above) | |
| # For deployment_status and workflow_dispatch, always run | |
| if: | | |
| (github.event_name == 'pull_request' && | |
| github.event.pull_request.head.repo.fork == true && | |
| steps.fork-docs-check.outputs.any_changed == 'true') || | |
| (github.event_name == 'deployment_status' && | |
| steps.pr-context.outputs.pr_number != '') || | |
| github.event_name == 'workflow_dispatch' | |
| uses: tj-actions/[email protected] | |
| with: | |
| base_sha: ${{ steps.pr-context.outputs.base_sha || github.event.pull_request.base.sha }} | |
| sha: ${{ steps.pr-context.outputs.head_sha || github.event.pull_request.head.sha }} | |
| files: | | |
| **/*.md | |
| **/*.mdx | |
| - name: Get PR number | |
| id: get-pr | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| // For pull_request events, get PR number directly | |
| if (context.eventName === 'pull_request') { | |
| const prNumber = context.payload.pull_request.number; | |
| core.info(`PR number from pull_request event: ${prNumber}`); | |
| core.setOutput('pr_number', prNumber); | |
| return prNumber; | |
| } | |
| // For deployment_status events, find PR from commit SHA | |
| if (context.eventName === 'deployment_status') { | |
| const sha = context.payload.deployment?.sha; | |
| if (!sha) { | |
| core.warning('No deployment SHA found'); | |
| return null; | |
| } | |
| const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| commit_sha: sha, | |
| }); | |
| const pr = prs[0]; | |
| if (pr) { | |
| core.info(`Found PR #${pr.number} for deployment`); | |
| core.setOutput('pr_number', pr.number); | |
| return pr.number; | |
| } else { | |
| core.warning(`No PR found for commit ${sha}`); | |
| return null; | |
| } | |
| } | |
| core.warning(`Unsupported event type: ${context.eventName}`); | |
| return null; | |
| - name: Debug - Show lychee config being used | |
| if: | | |
| steps.pr-context.outputs.pr_closed != 'true' && | |
| ((github.event_name == 'pull_request' && | |
| github.event.pull_request.head.repo.fork == true && | |
| steps.fork-docs-check.outputs.any_changed == 'true') || | |
| (github.event_name == 'deployment_status' && | |
| steps.changed-files.outputs.any_changed == 'true') || | |
| github.event_name == 'workflow_dispatch') | |
| run: | | |
| echo "=== Lychee Configuration Debug ===" | |
| echo "Working directory: $(pwd)" | |
| echo "Git HEAD commit: $(git rev-parse HEAD)" | |
| echo "Git branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'detached HEAD')" | |
| echo "" | |
| if [ -f "lychee.toml" ]; then | |
| echo "✓ lychee.toml found in working directory" | |
| echo "Last modified by commit: $(git log -1 --format='%H %ai %s' -- lychee.toml)" | |
| echo "" | |
| echo "=== Current verbosity setting ===" | |
| grep "^verbose = " lychee.toml || echo "No verbose setting found" | |
| echo "" | |
| echo "=== Last 5 exclude patterns ===" | |
| grep -A 25 "^exclude = " lychee.toml | tail -6 | |
| else | |
| echo "✗ lychee.toml not found in working directory!" | |
| fi | |
| echo "==================================" | |
| - name: Link Checker | |
| id: lychee | |
| # Run if: | |
| # - Fork PR with doc changes | |
| # - Deployment status event with doc changes (and PR not closed) | |
| # - Manual workflow dispatch | |
| if: | | |
| steps.pr-context.outputs.pr_closed != 'true' && | |
| ((github.event_name == 'pull_request' && | |
| github.event.pull_request.head.repo.fork == true && | |
| steps.fork-docs-check.outputs.any_changed == 'true') || | |
| (github.event_name == 'deployment_status' && | |
| steps.changed-files.outputs.any_changed == 'true') || | |
| github.event_name == 'workflow_dispatch') | |
| uses: lycheeverse/lychee-action@v2 | |
| with: | |
| fail: false | |
| # Don't fail if no files to check | |
| failIfEmpty: false | |
| # Output format for reports | |
| format: markdown | |
| # GitHub token for API rate limiting | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| # Use deployment URL (from Mintlify preview) or fallback to production | |
| # Same-repo PRs: Wait for deployment and use preview URL | |
| # Fork PRs: Run immediately and check against production (no preview available) | |
| args: >- | |
| --config lychee.toml | |
| --base-url ${{ steps.pr-context.outputs.deploy_url || 'https://docs.wandb.ai' }} | |
| ${{ steps.fork-docs-check.outputs.all_changed_files || steps.changed-files.outputs.all_changed_files || '.' }} | |
| - name: Comment on PR with results | |
| # Comment if link checker ran | |
| if: | | |
| steps.get-pr.outputs.pr_number && | |
| (steps.lychee.conclusion == 'success' || | |
| steps.lychee.conclusion == 'failure') | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const prNumber = parseInt('${{ steps.get-pr.outputs.pr_number }}'); | |
| const exitCode = parseInt('${{ steps.lychee.outputs.exit_code }}') || 0; | |
| const deployUrl = '${{ steps.pr-context.outputs.deploy_url || github.event.deployment_status.environment_url }}'; | |
| const identifier = '<!-- lychee-link-checker-comment -->'; | |
| let commentBody = identifier + '\n'; | |
| const checkedAgainst = deployUrl || 'https://docs.wandb.ai'; | |
| if (exitCode === 0) { | |
| // Success - no broken links | |
| commentBody += '## 🔗 Link Checker Results\n\n'; | |
| commentBody += '✅ **All links are valid!**\n\n'; | |
| commentBody += 'No broken links were detected.\n'; | |
| commentBody += `\n_Checked against: ${checkedAgainst}_\n`; | |
| } else { | |
| // Issues found | |
| try { | |
| const report = fs.readFileSync('./lychee/out.md', 'utf8'); | |
| commentBody += '## 🔗 Link Checker Results\n\n'; | |
| commentBody += '⚠️ **Some issues were detected**\n\n'; | |
| commentBody += `_Checked against: ${checkedAgainst}_\n\n`; | |
| commentBody += '---\n\n'; | |
| commentBody += report; | |
| } catch (e) { | |
| commentBody += '## 🔗 Link Checker Results\n\n'; | |
| commentBody += '⚠️ Link checker found issues but could not read report.\n'; | |
| } | |
| } | |
| // Find and update existing comment, or create new one | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| }); | |
| const existingComment = comments.find(comment => | |
| comment.body?.includes(identifier) && comment.user?.login === 'github-actions[bot]' | |
| ); | |
| if (existingComment) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existingComment.id, | |
| body: commentBody | |
| }); | |
| core.info(`Updated comment ${existingComment.id} on PR #${prNumber}`); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body: commentBody | |
| }); | |
| core.info(`Created new comment on PR #${prNumber}`); | |
| } |