Skip to content

Commit 6df0112

Browse files
Merge pull request #564 from spencercjh/fix/pr563-linkcheck-diagnostics
fix(ci): surface actionable broken link failures
2 parents 5f2a730 + cb6a75c commit 6df0112

1 file changed

Lines changed: 122 additions & 10 deletions

File tree

scripts/check-links.sh

Lines changed: 122 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ set -euo pipefail
55
host="${LINK_CHECK_HOST:-127.0.0.1}"
66
port="${LINK_CHECK_PORT:-3000}"
77
root_url="http://${host}:${port}"
8+
sirv_package="${SIRV_PACKAGE:-sirv-cli@3.0.1}"
9+
linkinator_package="${LINKINATOR_PACKAGE:-linkinator@7.6.1}"
810
tmpdir="$(mktemp -d)"
911
server_pid=""
1012

@@ -18,6 +20,115 @@ cleanup() {
1820

1921
trap cleanup EXIT INT TERM
2022

23+
resolve_npx_bin() {
24+
local package="$1"
25+
local bin_name="$2"
26+
npx --yes --package "${package}" bash -lc "readlink -f \"\$(command -v ${bin_name})\""
27+
}
28+
29+
print_linkinator_summary() {
30+
python3 - "${tmpdir}/linkinator.json" "${tmpdir}/linkinator.stderr" <<'PY'
31+
import json
32+
import sys
33+
from pathlib import Path
34+
35+
stdout_path = Path(sys.argv[1])
36+
stderr_path = Path(sys.argv[2])
37+
stdout_text = stdout_path.read_text(errors="replace")
38+
stderr_text = stderr_path.read_text(errors="replace").strip()
39+
40+
if not stdout_text.strip():
41+
print("Linkinator did not emit a JSON payload.")
42+
if stderr_text:
43+
print("linkinator stderr:")
44+
print(stderr_text)
45+
sys.exit(0)
46+
47+
try:
48+
payload = json.loads(stdout_text)
49+
except json.JSONDecodeError as exc:
50+
print(f"Failed to parse linkinator JSON output: {exc}")
51+
print("Raw linkinator stdout:")
52+
print(stdout_text.rstrip())
53+
if stderr_text:
54+
print("linkinator stderr:")
55+
print(stderr_text)
56+
sys.exit(0)
57+
58+
links = payload.get("links") or []
59+
problematic = []
60+
61+
for entry in links:
62+
state = str(entry.get("state") or "").upper()
63+
status = entry.get("status")
64+
http_error = status == 0 or (isinstance(status, int) and (status < 200 or status >= 400))
65+
if state in {"OK", "SKIPPED"} and not http_error:
66+
continue
67+
problematic.append(entry)
68+
69+
if problematic:
70+
print(f"Actionable link entries ({len(problematic)} total, showing up to 50):")
71+
for entry in problematic[:50]:
72+
status = entry.get("status", "<none>")
73+
state = entry.get("state", "<none>")
74+
url = entry.get("url") or "<missing url>"
75+
print(f"- status={status} state={state} url={url}")
76+
77+
parent = entry.get("parent")
78+
parents = parent if isinstance(parent, list) else ([parent] if parent else [])
79+
for item in parents[:5]:
80+
print(f" parent={item}")
81+
if len(parents) > 5:
82+
print(f" parent=... {len(parents) - 5} more")
83+
84+
failure = entry.get("failureDetails")
85+
if failure:
86+
if not isinstance(failure, str):
87+
failure = json.dumps(failure, ensure_ascii=False)
88+
print(f" failureDetails={failure}")
89+
else:
90+
print("Linkinator returned a non-zero exit code but no actionable link entries were present in the JSON payload.")
91+
print("Raw linkinator JSON:")
92+
print(json.dumps(payload, ensure_ascii=False))
93+
print("Check the sirv summary below for concrete missing paths.")
94+
95+
if stderr_text:
96+
print("linkinator stderr:")
97+
print(stderr_text)
98+
PY
99+
}
100+
101+
print_sirv_summary() {
102+
python3 - "${tmpdir}/sirv.log" <<'PY'
103+
import re
104+
import sys
105+
from pathlib import Path
106+
107+
path = Path(sys.argv[1])
108+
pattern = re.compile(r"\[\d{2}:\d{2}:\d{2}\]\s+(\d{3})\s+.*\s+─\s+(.+)$")
109+
offenders = []
110+
111+
for line in path.read_text(errors="replace").splitlines():
112+
match = pattern.search(line)
113+
if not match:
114+
continue
115+
status = int(match.group(1))
116+
if 200 <= status < 400:
117+
continue
118+
offenders.append(line)
119+
120+
if offenders:
121+
print("\n".join(offenders[:100]))
122+
if len(offenders) > 100:
123+
print(f"... truncated {len(offenders) - 100} additional sirv lines")
124+
else:
125+
print("No non-2xx/3xx sirv responses captured.")
126+
PY
127+
}
128+
129+
sirv_bin="$(resolve_npx_bin "${sirv_package}" sirv)"
130+
linkinator_cli="$(resolve_npx_bin "${linkinator_package}" linkinator)"
131+
21132
python3 - "${host}" > "${tmpdir}/skip-args.txt" <<'PY'
22133
import re
23134
import sys
@@ -46,7 +157,7 @@ for external_host in sorted(hosts):
46157
print(f"^https?://{re.escape(external_host)}(?:/|$)")
47158
PY
48159

49-
npx --yes sirv-cli build -D -H "${host}" -p "${port}" > "${tmpdir}/sirv.log" 2>&1 &
160+
"${sirv_bin}" build -D -H "${host}" -p "${port}" > "${tmpdir}/sirv.log" 2>&1 &
50161
server_pid=$!
51162

52163
for _ in $(seq 1 60); do
@@ -62,25 +173,26 @@ curl -fsS "${root_url}/" >/dev/null 2>&1 || {
62173
exit 1
63174
}
64175

65-
tmp_linkinator="${tmpdir}/linkinator.log"
66176
mapfile -t skip_args < "${tmpdir}/skip-args.txt"
67177

68178
set +e
69-
# Call linkinator directly so its real exit code and stderr survive failures.
70-
npx --yes linkinator "${root_url}" \
179+
# The human-readable formatter can fail without surfacing the offender URL under Node 20.
180+
# Use the direct CLI + JSON path and print our own summary instead.
181+
node "${linkinator_cli}" "${root_url}" \
71182
--recurse \
72183
--concurrency 10 \
73184
--retry-errors \
74185
--retry-errors-count 2 \
75-
--verbosity error \
76-
"${skip_args[@]}" > "${tmp_linkinator}" 2>&1
186+
--verbosity none \
187+
--format json \
188+
"${skip_args[@]}" > "${tmpdir}/linkinator.json" 2> "${tmpdir}/linkinator.stderr"
77189
linkinator_exit=$?
78190
set -e
79191

80192
if [[ "${linkinator_exit}" -ne 0 ]]; then
81-
echo "Linkinator failed with exit code ${linkinator_exit}. Output:"
82-
cat "${tmp_linkinator}"
83-
echo "End of linkinator output. sirv log:"
84-
cat "${tmpdir}/sirv.log"
193+
echo "Linkinator failed with exit code ${linkinator_exit}. Summary:"
194+
print_linkinator_summary
195+
echo "End of linkinator summary. sirv non-2xx/3xx responses:"
196+
print_sirv_summary
85197
exit "${linkinator_exit}"
86198
fi

0 commit comments

Comments
 (0)