Skip to content

Link checker - PR changed files #1351

Link checker - PR changed files

Link checker - PR changed files #1351

Workflow file for this run

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}`);
}