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
70 changes: 70 additions & 0 deletions .github/workflows/link-check.yml
Original file line number Diff line number Diff line change
@@ -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)"
61 changes: 61 additions & 0 deletions check_links.py
Original file line number Diff line number Diff line change
@@ -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")