diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml new file mode 100644 index 0000000..ed04d53 --- /dev/null +++ b/.github/workflows/links.yml @@ -0,0 +1,82 @@ +name: Broken Link Checker + +on: + push: + branches: + - main + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + workflow_dispatch: + +permissions: + contents: read + +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + link-checker: + name: Check Links + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-link-checker + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Check links with lychee + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + args: >- + --verbose + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --timeout 30 + --exclude-path docs/case-studies + './**/*.md' + './**/*.html' + fail: false + output: lychee/out.md + jobSummary: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check broken links against Web Archive + if: steps.lychee.outputs.exit_code != 0 + id: webarchive + run: python scripts/check_web_archive.py + env: + LYCHEE_OUTPUT: lychee/out.md + + - name: Fail if broken links found and no web archive fallback + if: steps.lychee.outputs.exit_code != 0 && steps.webarchive.outputs.all_archived != 'true' + run: | + echo "::error::Broken links were detected with no Web Archive fallback available." + echo "" + echo "What happened:" + echo " lychee found one or more broken links in the *.md and *.html files of this repository." + echo " The Web Archive check found no archived versions for some of them." + echo "" + echo "How to fix:" + echo " 1. Review the 'Check links with lychee' step above for the broken links." + echo " 2. Replace links with suggested archive.org URLs when available." + echo " 3. Otherwise find an updated URL, remove the link, or add a known false positive to .lycheeignore." + echo "" + echo "Report location: lychee/out.md." + exit 1 diff --git a/changelog.d/20260809_issue_49_broken_links.md b/changelog.d/20260809_issue_49_broken_links.md new file mode 100644 index 0000000..5304cf4 --- /dev/null +++ b/changelog.d/20260809_issue_49_broken_links.md @@ -0,0 +1,3 @@ +### Added + +- Add automated Markdown and HTML link validation with a Wayback Machine fallback. diff --git a/scripts/check_web_archive.py b/scripts/check_web_archive.py new file mode 100644 index 0000000..ef70fb2 --- /dev/null +++ b/scripts/check_web_archive.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Check broken links for snapshots in the Wayback Machine.""" + +from __future__ import annotations + +import json +import os +import re +import sys +import time +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +WAYBACK_API = "https://archive.org/wayback/available?url=" +DEFAULT_LYCHEE_OUTPUT = Path("lychee/out.md") +REQUEST_TIMEOUT_SECONDS = 10 +REQUEST_DELAY_SECONDS = 0.5 +USER_AGENT = "broken-link-checker/1.0 (GitHub Actions CI)" + +STATUS_URL_PATTERN = re.compile( + r"\[(?:4\d\d|5\d\d|ERROR|TIMEOUT|UNKNOWN)\]\s+" r"(https?://[^\s)]+)", + re.IGNORECASE, +) +BULLET_URL_PATTERN = re.compile( + r"^\s*(?:\*|-)\s+.*?(https?://[^\s|)>\]]+)", re.MULTILINE +) + + +@dataclass(frozen=True) +class ArchiveResult: + """Availability details for one Wayback Machine snapshot.""" + + available: bool + archive_url: str | None = None + timestamp: str | None = None + + +def extract_broken_urls(content: str) -> list[str]: + """Extract and deduplicate broken HTTP URLs from a lychee Markdown report.""" + urls: list[str] = [] + for pattern in (STATUS_URL_PATTERN, BULLET_URL_PATTERN): + for match in pattern.finditer(content): + url = match.group(1).strip().rstrip(".,;!?") + if url not in urls: + urls.append(url) + return urls + + +def fetch_json(url: str) -> dict[str, Any]: + """Fetch a JSON object with a bounded Wayback Machine request.""" + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen( # noqa: S310 - URL targets a fixed HTTPS API + request, timeout=REQUEST_TIMEOUT_SECONDS + ) as response: + payload = json.load(response) + if not isinstance(payload, dict): + msg = "Wayback Machine returned a non-object JSON response" + raise ValueError(msg) + return payload + + +def check_wayback_machine(url: str) -> ArchiveResult: + """Return the closest available Wayback Machine snapshot for ``url``.""" + api_url = f"{WAYBACK_API}{urllib.parse.quote(url, safe='')}" + try: + payload = fetch_json(api_url) + snapshots = payload.get("archived_snapshots", {}) + closest = snapshots.get("closest", {}) if isinstance(snapshots, dict) else {} + if not isinstance(closest, dict) or closest.get("available") is not True: + return ArchiveResult(available=False) + + archive_url = closest.get("url") + timestamp = closest.get("timestamp") + if not isinstance(archive_url, str) or not isinstance(timestamp, str): + return ArchiveResult(available=False) + return ArchiveResult( + available=True, + archive_url=re.sub(r"^http://", "https://", archive_url), + timestamp=timestamp, + ) + except (OSError, TimeoutError, ValueError, json.JSONDecodeError) as error: + print(f" Failed to check Wayback Machine for {url}: {error}") + return ArchiveResult(available=False) + + +def format_timestamp(timestamp: str | None) -> str: + """Format a Wayback timestamp as YYYY-MM-DD when possible.""" + if timestamp is None or len(timestamp) < 8: + return timestamp or "unknown date" + return f"{timestamp[:4]}-{timestamp[4:6]}-{timestamp[6:8]}" + + +def set_output(name: str, value: str) -> None: + """Publish a GitHub Actions output and echo it for local runs.""" + output_file = os.environ.get("GITHUB_OUTPUT") + if output_file: + with Path(output_file).open("a", encoding="utf-8") as stream: + stream.write(f"{name}={value}\n") + print(f"{name}={value}") + + +def report_archived(url: str, result: ArchiveResult) -> None: + """Emit an actionable GitHub notice for an archived broken link.""" + date = format_timestamp(result.timestamp) + print(f" Archived on {date}: {result.archive_url}") + print( + f"::notice title=Broken link - Web Archive available ({date})::" + f"Broken link detected: {url}\n" + f"A Web Archive snapshot from {date} is available.\n" + "Suggested fix: replace the broken link with the archived version:\n" + f" {result.archive_url}" + ) + + +def report_unarchived(url: str) -> None: + """Emit an actionable GitHub error for an unrecoverable broken link.""" + print(" Not found in Web Archive") + print( + "::error title=Broken link - No Web Archive fallback::" + f"Broken link detected: {url}\n" + "No archived version was found in the Wayback Machine.\n" + "Find an updated URL, remove the link, or add it to .lycheeignore " + "if it is a known false positive." + ) + + +def main() -> int: + """Check every URL in the configured lychee report.""" + output_path = Path(os.environ.get("LYCHEE_OUTPUT", DEFAULT_LYCHEE_OUTPUT)) + print("=== Web Archive Fallback Check ===") + print(f"Reading lychee output from: {output_path}") + + if not output_path.exists(): + print("No lychee output file found. Skipping web archive check.") + set_output("all_archived", "true") + return 0 + + broken_urls = extract_broken_urls(output_path.read_text(encoding="utf-8")) + if not broken_urls: + print("No broken URLs found in lychee output.") + set_output("all_archived", "true") + return 0 + + unarchived: list[str] = [] + print(f"Found {len(broken_urls)} broken URL(s). Checking Web Archive...") + for index, url in enumerate(broken_urls): + print(f"Checking: {url}") + result = check_wayback_machine(url) + if result.available: + report_archived(url, result) + else: + unarchived.append(url) + report_unarchived(url) + if index < len(broken_urls) - 1: + time.sleep(REQUEST_DELAY_SECONDS) + + all_archived = not unarchived + set_output("all_archived", "true" if all_archived else "false") + if not all_archived: + print("Action required: fix or remove the broken links listed above.") + return 1 + print("All broken links have Web Archive versions.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_check_web_archive.py b/tests/test_check_web_archive.py new file mode 100644 index 0000000..2c9d215 --- /dev/null +++ b/tests/test_check_web_archive.py @@ -0,0 +1,67 @@ +"""Tests for the broken-link Web Archive fallback helper.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +SCRIPT_PATH = ( + Path(__file__).resolve().parent.parent / "scripts" / "check_web_archive.py" +) +spec = importlib.util.spec_from_file_location("check_web_archive", SCRIPT_PATH) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) # type: ignore[union-attr] + + +def test_extract_broken_urls_supports_lychee_markdown_and_deduplicates() -> None: + """Every broken URL format emitted by lychee should be recognized once.""" + report = """ +* [404] https://example.com/missing +- [ERROR] https://example.org/offline | connection refused +* Failure at +* [500] https://example.com/missing +""" + + assert module.extract_broken_urls(report) == [ + "https://example.com/missing", + "https://example.org/offline", + "https://example.net/timeout", + ] + + +def test_check_wayback_machine_returns_available_https_snapshot(monkeypatch) -> None: + """An available snapshot should be normalized to an HTTPS archive URL.""" + payload = { + "archived_snapshots": { + "closest": { + "available": True, + "url": "http://web.archive.org/web/20240102030405/https://example.com", + "timestamp": "20240102030405", + } + } + } + monkeypatch.setattr(module, "fetch_json", lambda _url: payload) + + result = module.check_wayback_machine("https://example.com") + + assert result.available is True + assert result.archive_url.startswith("https://web.archive.org/") + assert result.timestamp == "20240102030405" + + +def test_check_wayback_machine_treats_api_errors_as_unavailable(monkeypatch) -> None: + """A Wayback outage must not incorrectly approve a broken documentation URL.""" + + def fail(_url: str) -> dict[str, object]: + raise OSError("temporary outage") + + monkeypatch.setattr(module, "fetch_json", fail) + + result = module.check_wayback_machine("https://example.com") + + assert result.available is False + assert result.archive_url is None + assert result.timestamp is None diff --git a/tests/test_workflows.py b/tests/test_workflows.py index ab62e58..b315f08 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -134,6 +134,35 @@ def test_security_workflow_scans_code_actions_and_dependencies() -> None: assert "comment-summary-in-pr: on-failure" in dependency_job +def test_links_workflow_checks_docs_with_web_archive_fallback() -> None: + """Markdown and HTML changes must trigger the bounded broken-link check.""" + workflow = read_workflow("links.yml") + link_job = workflow_job_block(workflow, "link-checker") + lychee_step = workflow_step_block(link_job, "Check links with lychee") + archive_step = workflow_step_block( + link_job, "Check broken links against Web Archive" + ) + failure_step = workflow_step_block( + link_job, "Fail if broken links found and no web archive fallback" + ) + + assert "- '**.md'" in workflow + assert "- '**.html'" in workflow + assert "permissions:\n contents: read" in workflow + assert "timeout-minutes: 10" in link_job + assert "cancel-in-progress: true" in link_job + assert "uses: actions/checkout@v6" in link_job + assert "uses: lycheeverse/lychee-action@v2" in lychee_step + assert "--exclude-path docs/case-studies" in lychee_step + assert "examples/universal-app/index.html" not in lychee_step + assert "fail: false" in lychee_step + assert "output: lychee/out.md" in lychee_step + assert "if: steps.lychee.outputs.exit_code != 0" in archive_step + assert "python scripts/check_web_archive.py" in archive_step + assert "steps.webarchive.outputs.all_archived != 'true'" in failure_step + assert "exit 1" in failure_step + + def test_changelog_check_safely_requires_a_fragment() -> None: """Source-changing pull requests must fail safely without a fragment.""" workflow = read_workflow("release.yml")