From 2b1e475676eb6a6f3ee73e1c43aa339c15eb580e Mon Sep 17 00:00:00 2001 From: foxxx009 Date: Mon, 27 Jul 2026 13:06:17 +0800 Subject: [PATCH 1/4] feat: add link-check CI workflow to prevent broken links - Add lychee-based link checker for external URLs - Add relative link validation script - Runs on push/PR to main and weekly schedule - Enforces relative links, non-blocking for external links Closes #7910, #7908, #7886, #7885, #7792 --- .github/workflows/link-check.yml | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/link-check.yml diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml new file mode 100644 index 000000000..3ce575af6 --- /dev/null +++ b/.github/workflows/link-check.yml @@ -0,0 +1,61 @@ +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 + find . -name "*.md" -not -path "./.git/*" | while read file; do + echo "Checking $file..." + # Extract relative links and check if they exist + grep -oE '\[([^\]]*)\]\(([^)]+)\)' "$file" | while IFS= read -r link; do + url=$(echo "$link" | sed 's/.*](\([^)]*\)).*/\1/') + # Skip anchors, mailto, and http links + if [[ "$url" == \#* ]] || [[ "$url" == mailto:* ]] || [[ "$url" == http* ]]; then + continue + fi + # Check if file exists + if [ ! -f "$url" ] && [ ! -d "$url" ]; then + echo "❌ Broken link in $file: $url" + exit 1 + fi + done + done + 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)" From 59d15361af8c8804053e7bb56190721149648585 Mon Sep 17 00:00:00 2001 From: foxxx009 Date: Mon, 27 Jul 2026 13:08:22 +0800 Subject: [PATCH 2/4] chore: add check_links.py script --- check_links.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 check_links.py diff --git a/check_links.py b/check_links.py new file mode 100644 index 000000000..07ed8c213 --- /dev/null +++ b/check_links.py @@ -0,0 +1,58 @@ +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") From 8b79697438f1c24b53cb775cc448681739c55f0e Mon Sep 17 00:00:00 2001 From: foxxx009 Date: Mon, 27 Jul 2026 14:04:11 +0800 Subject: [PATCH 3/4] fix: add SPDX license header to check_links.py --- check_links.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/check_links.py b/check_links.py index 07ed8c213..0824cd094 100644 --- a/check_links.py +++ b/check_links.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: MIT +"""Link checker for RustChain documentation.""" + import re import requests from urllib.parse import urljoin, urlparse From 203f0542965d58aade4d4c31f6a835f22b038c9b Mon Sep 17 00:00:00 2001 From: foxxx009 Date: Tue, 28 Jul 2026 15:05:02 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20resolve=20CI=20exit=20code=20loss=20?= =?UTF-8?q?in=20relative-link=20check=20subshell=20=20The=20original=20fin?= =?UTF-8?q?d=20...=20|=20while=20read=20pipe=20created=20a=20subshell,=20s?= =?UTF-8?q?o=20'exit=201'=20inside=20the=20inner=20loop=20only=20exited=20?= =?UTF-8?q?the=20subshell=20=E2=80=94=20the=20CI=20job=20always=20passed?= =?UTF-8?q?=20regardless=20of=20broken=20links.=20=20Fix:=20use=20process?= =?UTF-8?q?=20substitution=20(<())=20with=20-print0=20to=20avoid=20pipes.?= =?UTF-8?q?=20ERRORS=20counter=20is=20now=20checked=20after=20the=20loop;?= =?UTF-8?q?=20a=20non-zero=20count=20triggers=20'exit=201'=20in=20the=20ma?= =?UTF-8?q?in=20script=20shell,=20properly=20failing=20the=20job.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/link-check.yml | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index 3ce575af6..a8b50b26b 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -25,22 +25,31 @@ jobs: - name: Check relative links in docs run: | # Check all markdown files for broken relative links - find . -name "*.md" -not -path "./.git/*" | while read file; do + # 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..." - # Extract relative links and check if they exist - grep -oE '\[([^\]]*)\]\(([^)]+)\)' "$file" | while IFS= read -r link; do - url=$(echo "$link" | sed 's/.*](\([^)]*\)).*/\1/') + 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 - if [ ! -f "$url" ] && [ ! -d "$url" ]; then + # 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" - exit 1 + ERRORS=$((ERRORS + 1)) fi - done - done + 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)