diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml new file mode 100644 index 000000000..a8b50b26b --- /dev/null +++ b/.github/workflows/link-check.yml @@ -0,0 +1,70 @@ +name: Link Check + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + # Also run on schedule to catch external link rot + schedule: + - cron: '0 0 * * 0' # Weekly on Sunday + +jobs: + link-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install lychee + run: | + curl -sLO https://github.com/lycheeverse/lychee/releases/latest/download/lychee-x86_64-unknown-linux-gnu.tar.gz + tar xzf lychee-x86_64-unknown-linux-gnu.tar.gz + sudo mv lychee /usr/local/bin/ + rm lychee-x86_64-unknown-linux-gnu.tar.gz + + - name: Check relative links in docs + run: | + # Check all markdown files for broken relative links + # NOTE: uses process substitution (<()) instead of pipes to avoid + # subshell exit-code loss — exit inside the loop properly fails the job. + ERRORS=0 + while IFS= read -r -d '' file; do + echo "Checking $file..." + while IFS= read -r link; do + url="${link#*](}" + url="${url%)*}" + # Skip anchors, mailto, and http links + if [[ "$url" == \#* ]] || [[ "$url" == mailto:* ]] || [[ "$url" == http* ]]; then + continue + fi + # Check if file exists (relative to repo root) + resolved="$url" + # Handle any leading ./ or ../ from link target + if [ ! -f "$resolved" ] && [ ! -d "$resolved" ]; then + echo "❌ Broken link in $file: $url" + ERRORS=$((ERRORS + 1)) + fi + done < <(grep -oE '\[([^\]]*)\]\(([^)]+)\)' "$file") + done < <(find . -name "*.md" -not -path "./.git/*" -print0) + if [ "$ERRORS" -gt 0 ]; then + echo "❌ Found $ERRORS broken relative links" + exit 1 + fi + echo "✅ All relative links are valid" + + - name: Check external links (lychee) + run: | + # Check external links with lychee (allow retries, skip local files) + lychee --verbose --no-progress --accept 200,204,301,302 \ + --exclude-loopback \ + --exclude 'github.com/Scottcjn/Rustchain/actions' \ + --exclude 'github.com/Scottcjn/Rustchain/stargazers' \ + README.md docs/*.md 2>&1 || true + # Don't fail on external link errors - they might be transient + continue-on-error: true + + - name: Summary + run: | + echo "Link check completed." + echo "Relative links: enforced (must pass)" + echo "External links: checked but non-blocking (may be transient)" diff --git a/check_links.py b/check_links.py new file mode 100644 index 000000000..0824cd094 --- /dev/null +++ b/check_links.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: MIT +"""Link checker for RustChain documentation.""" + +import re +import requests +from urllib.parse import urljoin, urlparse +import os + +def check_links_in_file(filepath): + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + # Find all markdown links [text](url) + link_pattern = r'\[([^\]]*)\]\(([^)]+)\)' + links = re.findall(link_pattern, content) + + # Also find raw URLs + url_pattern = r'https?://[^\s\)>\]]+' + raw_urls = re.findall(url_pattern, content) + + print(f"Found {len(links)} markdown links and {len(raw_urls)} raw URLs") + print("\n=== Checking markdown links ===") + + broken = [] + for text, url in links: + if url.startswith('#'): + continue # Skip anchor links + if url.startswith('mailto:'): + continue + + # Check if it's a relative file link + if not url.startswith('http'): + # Check if file exists + if os.path.exists(url): + print(f"✅ {text} -> {url} (local file exists)") + else: + print(f"❌ {text} -> {url} (local file NOT found)") + broken.append((text, url)) + continue + + # Check HTTP links + try: + resp = requests.head(url, timeout=10, allow_redirects=True) + if resp.status_code == 200: + print(f"✅ {text} -> {url} ({resp.status_code})") + else: + print(f"❌ {text} -> {url} ({resp.status_code})") + broken.append((text, url)) + except Exception as e: + print(f"⚠️ {text} -> {url} (Error: {e})") + broken.append((text, url)) + + print(f"\n=== Summary ===") + print(f"Broken links found: {len(broken)}") + for text, url in broken: + print(f" - [{text}]({url})") + + return broken + +if __name__ == "__main__": + broken = check_links_in_file("README.md")