Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions .github/workflows/link-check-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
name: PR link check

on:
pull_request:
paths:
- 'calico/**'
- 'calico-enterprise/**'
- 'calico-cloud/**'
- 'use-cases/**'
- 'calico_versioned_docs/**'
- 'calico-enterprise_versioned_docs/**'
- 'calico-cloud_versioned_docs/**'

# Cancel an older run when the PR gets a new push.
concurrency:
group: pr-link-check-${{ github.event.pull_request.number }}
cancel-in-progress: true

permissions:
contents: read
pull-requests: write

jobs:
link-check:
name: Check links on changed pages
runs-on: ubuntu-latest
timeout-minutes: 60

steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Enable Corepack
run: corepack enable

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'yarn'

- name: Build the site and the route map
run: make build
env:
LINK_CHECK_ROUTES: 'true'
NODE_OPTIONS: '--max-old-space-size=6000'
DOCUSAURUS_IGNORE_SSG_WARNINGS: 'true'

- name: Find the changed pages
id: pages
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
node scripts/changed-pages.js "$BASE_SHA" build/link-check-routes.json > pages.txt
COUNT=$(grep -c . pages.txt || true)
echo "count=$COUNT" >> "$GITHUB_OUTPUT"
echo "Changed pages ($COUNT):"
cat pages.txt

- name: Install Playwright Chrome
if: steps.pages.outputs.count != '0'
run: yarn playwright install --with-deps chrome

- name: Serve the site and check the changed pages
if: steps.pages.outputs.count != '0'
env:
CI: 'true'
run: |
# Join with commas so the list survives as one env var; the crawler splits on commas/newlines.
export PAGES_TO_CHECK="$(tr '\n' ',' < pages.txt)"
make test 2>&1 | tee link-check.log

- name: Upload the link-check log
if: always() && steps.pages.outputs.count != '0'
uses: actions/upload-artifact@v4
with:
name: link-check-log
path: link-check.log
if-no-files-found: ignore

- name: Comment on the PR when the check fails
if: failure() && steps.pages.outputs.count != '0'
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
BODY=comment.md
{
echo "## Link check failed"
echo ""
echo "The link check found broken links on the pages this PR changes."
echo ""
echo "Pages checked:"
echo '```'
cat pages.txt
echo '```'
echo ""
echo "Report (last 200 lines, full log is in the run artifact \"link-check-log\"):"
echo '```'
tail -n 200 link-check.log 2>/dev/null || echo "(no log captured)"
echo '```'
} > "$BODY"
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$BODY"
38 changes: 29 additions & 9 deletions __tests__/crawler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ test('Crawl the docs and execute tests', async () => {
const validityTest = process.env.VALIDITY_TEST ? process.env.VALIDITY_TEST.split(',') : [];
const validityTestFiles = process.env.VALIDITY_TEST_FILES ? process.env.VALIDITY_TEST_FILES.split(',') : [];
const isDeepCrawl = process.env.DEEP_CRAWL ? process.env.DEEP_CRAWL === 'true' : false;
// PR-scoped mode: when PAGES_TO_CHECK is set, crawl only those pages and do not follow links.
// URLS_TO_CHECK optionally narrows checking to specific links (line-level); empty means check all.
const parseScopeList = (v) => (v ? v.split(/[\n,]/).map((s) => s.trim()).filter(Boolean) : []);
const toPageUrl = (p) => {
let u = /^https?:\/\//i.test(p) ? p : `${DOCS}${p.startsWith('/') ? '' : '/'}${p}`;
if (isLocalHost) u = u.replace(PROD_REGEX, DOCS);
return u;
};
const pagesToCheck = parseScopeList(process.env.PAGES_TO_CHECK).map(toPageUrl);
const urlsToCheck = new Set(parseScopeList(process.env.URLS_TO_CHECK));
const isScoped = pagesToCheck.length > 0;
const fileRegex = /https?:\/\/[-a-zA-Z0-9()@:%._+~#?&/=]+?\.(ya?ml|zip|ps1|tgz|sh|exe|bat|json)/gi;
const varRegex = /\{\{[ \t]*[-\w\[\]]+[ \t]*}}/g;
const varSkipList = ['{{end}}'];
Expand Down Expand Up @@ -249,6 +260,7 @@ test('Crawl the docs and execute tests', async () => {
const testUrl = url.replace(PROD_REGEX, DOCS);
if (request.url === testUrl) url = testUrl;
}
if (urlsToCheck.size > 0 && !urlsToCheck.has(url)) continue;
checkAndUseLinkChecker(request.url, url);
}

Expand All @@ -258,11 +270,14 @@ test('Crawl the docs and execute tests', async () => {

testLiquid(allText, request.url);

await enqueueLinks({
strategy: EnqueueStrategy.All,
transformRequestFunction: transformRequest,
userData: { origin: request.url },
});
// In PR-scoped mode, do not follow links: check only the seeded pages.
if (!isScoped) {
await enqueueLinks({
strategy: EnqueueStrategy.All,
transformRequestFunction: transformRequest,
userData: { origin: request.url },
});
}
Comment on lines +273 to +280

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Internal route links are already validated by the build step: make build runs with onBrokenLinks: 'throw', so a PR that introduces a broken internal route fails before the crawl even starts. The scoped crawl intentionally covers external and code-block URLs on the changed pages. Broken anchors are a separate track (onBrokenAnchors is 'warn'; see DOCS-2979/2980). I've updated the PR description to state this explicitly.

},
});
}
Expand Down Expand Up @@ -599,10 +614,15 @@ test('Crawl the docs and execute tests', async () => {
}

const crawler = getCrawler();
await processSiteMap(SITEMAP_URL);
const urls = [...urlCache.keys()].filter((url) => !url.endsWith(SITEMAP));
await crawler.addRequests([DOCS]);
await crawler.addRequests(urls);
if (isScoped) {
console.log(`PR-scoped mode: checking ${pagesToCheck.length} page(s) only.`);
await crawler.addRequests(pagesToCheck);
} else {
await processSiteMap(SITEMAP_URL);
const urls = [...urlCache.keys()].filter((url) => !url.endsWith(SITEMAP));
await crawler.addRequests([DOCS]);
await crawler.addRequests(urls);
}

console.log(`Crawling the docs (${DOCS}) and executing tests.`);
console.log(`Localhost mode is ${isLocalHost ? 'ON' : 'OFF'}.`);
Expand Down
2 changes: 2 additions & 0 deletions docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,8 @@ export default async function createAsyncConfig() {
},
plugins: [
'docusaurus-plugin-sass',
// Writes build/link-check-routes.json when LINK_CHECK_ROUTES=true. Used by the PR link check.
'./src/plugins/docusaurus-plugin-link-check-routes',
[
'@docusaurus/plugin-content-docs',
/** @type {import('@docusaurus/plugin-content-docs').Options} */
Expand Down
70 changes: 70 additions & 0 deletions scripts/changed-pages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env node
/**
* changed-pages.js
*
* Print the built page URLs for the docs files changed in a PR, one per line.
* The PR link-check workflow feeds this list to the crawler as PAGES_TO_CHECK.
*
* Usage:
* node scripts/changed-pages.js [baseRef] [manifestPath]
*
* Defaults: baseRef = origin/main, manifestPath = build/link-check-routes.json.
* The manifest is written by the docusaurus-plugin-link-check-routes plugin
* during a build run with LINK_CHECK_ROUTES=true.
*
* Note: only files registered as docs pages map to a URL. Changes to partials,
* includes, or components are reported as "unmatched" and are not checked here.
*/

const { execFileSync } = require('child_process');
const fs = require('fs');

const base = process.argv[2] || process.env.BASE_REF || 'origin/main';
const manifestPath = process.argv[3] || 'build/link-check-routes.json';

const DOC_RE = /\.mdx?$/;

function changedDocFiles(baseRef) {
// execFile with an argument array: no shell, so baseRef cannot inject commands.
const out = execFileSync('git', ['diff', '--name-only', `${baseRef}...HEAD`], { encoding: 'utf8' });
return out
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
.filter((f) => DOC_RE.test(f));
}

let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
} catch (e) {
console.error(`[changed-pages] cannot read manifest ${manifestPath}: ${e.message}`);
process.exit(2);
}

const files = changedDocFiles(base);
const permalinks = new Set();
const unmatched = [];

for (const f of files) {
const links = manifest[f];
if (links && links.length) {
links.forEach((l) => permalinks.add(l));
} else {
unmatched.push(f);
}
}

// Pages go to stdout (consumed by the workflow); diagnostics go to stderr.
for (const p of [...permalinks].sort()) {
console.log(p);
}

console.error(
`[changed-pages] base=${base} changed-docs=${files.length} pages=${permalinks.size} unmatched=${unmatched.length}`
);
if (unmatched.length) {
console.error(
`[changed-pages] unmatched (partials/includes/components, not checked): ${unmatched.slice(0, 10).join(', ')}${unmatched.length > 10 ? '...' : ''}`
);
}
58 changes: 58 additions & 0 deletions src/plugins/docusaurus-plugin-link-check-routes/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* docusaurus-plugin-link-check-routes
*
* A Docusaurus postBuild plugin that writes a map of source file to built URL.
* The PR link check uses this map to turn "files changed in a PR" into "pages to check".
*
* Output: <build>/link-check-routes.json, shaped as:
* { "calico/getting-started/install.mdx": ["/calico/latest/getting-started/install/"], ... }
*
* Gated by LINK_CHECK_ROUTES=true — no-op on regular builds, so it does not change normal output.
*/

import fs from 'fs/promises';
import path from 'path';

const PLUGIN_NAME = 'docusaurus-plugin-link-check-routes';
const LOG_PREFIX = '[link-check-routes]';
const OUTPUT_FILE = 'link-check-routes.json';

export default function linkCheckRoutesPlugin() {
return {
name: PLUGIN_NAME,

async postBuild({ outDir, plugins }) {
if (process.env.LINK_CHECK_ROUTES !== 'true') {
return;
}

// sourcePath (repo-relative) -> array of permalinks (one source can map to more than one).
const routes = {};
const add = (source, permalink) => {
if (!source || !permalink) return;
const rel = source.replace(/^@site\//, '');
if (!routes[rel]) routes[rel] = [];
if (!routes[rel].includes(permalink)) routes[rel].push(permalink);
};

const docsPlugins = plugins.filter(
(p) => p.name === 'docusaurus-plugin-content-docs'
);

for (const dp of docsPlugins) {
const versions = dp.content?.loadedVersions || [];
for (const version of versions) {
for (const doc of version.docs || []) {
add(doc.source, doc.permalink);
}
}
}

const outPath = path.join(outDir, OUTPUT_FILE);
await fs.writeFile(outPath, JSON.stringify(routes, null, 2));
console.log(
`${LOG_PREFIX} Wrote ${outPath} (${Object.keys(routes).length} source files).`
);
},
};
}