Skip to content
Merged
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
82 changes: 82 additions & 0 deletions .github/workflows/links.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions changelog.d/20260809_issue_49_broken_links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- Add automated Markdown and HTML link validation with a Wayback Machine fallback.
170 changes: 170 additions & 0 deletions scripts/check_web_archive.py
Original file line number Diff line number Diff line change
@@ -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())
67 changes: 67 additions & 0 deletions tests/test_check_web_archive.py
Original file line number Diff line number Diff line change
@@ -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 <https://example.net/timeout>
* [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
29 changes: 29 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading