From 73c0dfe88b7b930732559a0bde29b33342cc582a Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 9 Aug 2026 11:22:58 +0200 Subject: [PATCH] ci(docs): treat non-404 4xx link-check failures as warnings, not errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markdown-link-check has no concept of "warning" vs "error" — it's a binary alive/dead per link, so a transient 429 from a rate-limiting site we don't control (recently: blogspot.com, izndgroup.com) fails the whole CI job exactly like a genuine dead link, with no way to tell them apart from the job's exit code. New scripts/check-doc-links.sh wraps the tool per file, parses its "[✖] → Status: " output, and re-decides pass/fail per link: 404 still fails the build (a real dead link, worth fixing), other 4xx (429, 401, 403, ...) become a GitHub Actions ::warning:: annotation instead, and anything else (5xx, timeouts, DNS failures) still fails the build same as before. De-dupes markdown-link-check's own doubled -v output. Written for bash 3.2 (macOS's default /bin/bash) so it's testable locally, not just on the ubuntu-latest runner. Existing retry config in .github/markdown-link-check.json (retryOn429, 3 retries, 30s backoff) is untouched; this only changes what happens once retries are exhausted. Verified locally: 6 synthetic scenarios (404/429/500/mixed/clean/ duplicate-line) via a stubbed markdown-link-check, plus a real run against the docs tree with the actual tool. --- .github/workflows/ci.yml | 2 +- scripts/check-doc-links.sh | 56 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100755 scripts/check-doc-links.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4a656a..d5149b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -197,7 +197,7 @@ jobs: - name: Check documentation links run: | npm install -g markdown-link-check - find . -name "*.md" -not -path "./tests/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 markdown-link-check -q -v -c .github/markdown-link-check.json + ./scripts/check-doc-links.sh - name: Warn on pending images run: | diff --git a/scripts/check-doc-links.sh b/scripts/check-doc-links.sh new file mode 100755 index 0000000..1f4c930 --- /dev/null +++ b/scripts/check-doc-links.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Wraps markdown-link-check with a status-code-aware pass/fail policy that +# the tool itself doesn't support: a 404 is a real dead link (fails the +# build), but most other 4xx responses (429 rate-limited, 401/403 +# anti-bot/access-restricted, ...) just mean the checker couldn't get a +# clean answer from a site we don't control — those are downgraded to a +# GitHub Actions warning annotation instead of failing CI. Anything else +# (5xx, timeouts, DNS failures) still fails the build, same as before. +# +# Usage: scripts/check-doc-links.sh [root-dir] +# Requires markdown-link-check on PATH and .github/markdown-link-check.json. +set -euo pipefail + +root="${1:-.}" +config=".github/markdown-link-check.json" +had_error=0 + +while IFS= read -r -d '' file; do + echo "Checking links in $file" + + set +e + output=$(markdown-link-check -q -v -c "$config" "$file" 2>&1) + set -e + + echo "$output" + + # markdown-link-check's -v mode logs each dead link more than once + # (inline with the raw error detail, then again in its summary); de-dupe + # on (url, code) so each broken link gets one annotation. A plain + # delimited string, not an associative array (bash 3.2, macOS's + # default /bin/bash, predates those), with a sentinel on both sides of + # each key so substring matches can't collide. + seen="|" + + while IFS= read -r line; do + [ -z "$line" ] && continue + + url=$(sed -E 's/^[[:space:]]*\[✖\] (.*) → Status: .*/\1/' <<<"$line") + code=$(sed -E 's/.*Status: ([0-9]+).*/\1/' <<<"$line") + + key="${url}#${code}|" + case "$seen" in + *"|${key}"*) continue ;; + esac + seen="${seen}${key}" + + if [[ "$code" =~ ^[0-9]+$ ]] && [ "$code" != "404" ] && [ "$code" -ge 400 ] && [ "$code" -lt 500 ]; then + echo "::warning file=${file}::Link check got HTTP ${code} (treated as non-fatal, see scripts/check-doc-links.sh): ${url}" + else + echo "::error file=${file}::Dead link (${code:-no response}): ${url}" + had_error=1 + fi + done < <(grep -F '[✖]' <<<"$output" || true) +done < <(find "$root" -name "*.md" -not -path "./tests/*" -not -path "./node_modules/*" -print0) + +exit "$had_error"