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
159 changes: 117 additions & 42 deletions .github/scripts/check-readme-links.sh
Original file line number Diff line number Diff line change
@@ -1,58 +1,133 @@
#!/usr/bin/env bash
# Every link in the packaged README must be absolute, and must point at a
# file that exists.
# Every link in the markdown we ship, or hand a contributor, must point at
# something that exists.
#
# `crates/termlens/Cargo.toml` sets `readme = "../../README.md"`, so this
# file is shipped as the crate's README and crates.io renders it there.
# crates.io rewrites a *relative* link against the crate's directory in the
# repository, not the repository root -- so `docs/DESIGN.md` became
# `…/blob/HEAD/crates/termlens/docs/DESIGN.md`, and every one of the ten
# relative links on the 0.10.2 page was a 404. Nothing in this repository
# could see it: the same file renders correctly on GitHub, where it *is* at
# the root.
# Two files are read, and the rules differ between them for a reason that is
# worth knowing before changing either half:
#
# So: absolute links only, and the path each one names must exist here.
# The second half is the more useful one -- it is offline, deterministic,
# and catches a renamed or deleted target, which a link checker that only
# asks GitHub would report as a 200 for the wrong reason or not at all.
# * `README.md` is packaged. `crates/termlens/Cargo.toml` sets
# `readme = "../../README.md"`, so crates.io renders it as the crate's
# README and rewrites a *relative* link against the crate's directory in the
# repository rather than the repository root -- `docs/DESIGN.md` became
# `…/crates/termlens/docs/DESIGN.md`, and every one of the ten relative
# links on the 0.10.2 page was a 404. Nothing in this repository could see
# it, because the same file renders correctly on GitHub, where it *is* at
# the root. So absolute links only, and that rule applies to `README.md`
# alone.
# * `CONTRIBUTING.md` is not packaged and legitimately uses relative links
# (`AGENTS.md`, `docs/RELEASING.md`, `.github/workflows/ci.yml`), so for it
# the relative form is fine. The target still has to exist, and until #352
# no file checked that.
#
# Usage: check-readme-links.sh [README.md]
# So each file gets the rule that generalises -- an in-repo target names a path
# that is really here -- and only the packaged one gets the absolute-only rule
# on top. Relative links are resolved against the repository root, which is
# where both files live.
#
# The other half is a URL naming no in-repo path at all: #351 shipped
# `…/vyncint/temlens/…` (one letter short) into `CONTRIBUTING.md` and all
# sixteen checks reported success. The account is asserted against the list
# below rather than asked over the network, deliberately: a link checker that
# asks GitHub is online, flaky, and can report a 200 for the wrong reason,
# which is the failure mode this script exists to avoid. `temlens` is not in
# the list.
#
# Usage: check-readme-links.sh [file ...] (default: README.md)
set -euo pipefail

readme="${1:-README.md}"
root=$(cd "$(dirname "$0")/../.." && pwd)
base="https://github.com/vyncint/termlens/blob/main/"
status=0

# `[text](target)` with a target that is neither absolute nor a fragment.
relative=$(grep -oE '\]\([^)]+\)' "$readme" \
| sed -E 's/^\]\(//; s/\)$//' \
| grep -vE '^(https?:|#|mailto:)' || true)
if [ -n "$relative" ]; then
echo "$relative" | while IFS= read -r link; do
echo "README LINK: relative target \"$link\" — crates.io rewrites it against" >&2
echo " crates/termlens/, where it does not exist. Use ${base}$link" >&2
done
status=1
# The repositories a document here has any business linking: the six projects
# that share this contributor pattern, plus termlens's own demo, which
# `docs/DESIGN.md` cites for the coverage study. The account holds many more
# repositories than these; the list is deliberately the short one, because a
# list of everything would wave through the typo this gate exists to catch.
#
# So a name outside it is *usually* a typo, and occasionally a real repository
# nobody has linked before -- which is why the message below says the list is
# what failed, rather than claiming the repository does not exist. Adding one
# is a word.
known_repos="launchbound mossaic oxidelake oxmera reconverge termlens termlens-demo"

files=("$@")
if [ "${#files[@]}" -eq 0 ]; then
files=(README.md)
fi

# Every in-repo absolute link names a path that is really here.
checked=0
missing=0
for url in $(grep -oE "${base}[^)]+" "$readme" | sort -u); do
path=${url#"$base"}
path=${path%%#*}
checked=$((checked + 1))
if [ ! -e "$root/$path" ] && [ ! -d "$root/$path" ]; then
echo "README LINK: \"$path\" is linked but not in the repository" >&2
missing=$((missing + 1))
root=$(cd "$(dirname "$0")/../.." && pwd)
status=0
total=0

for file in "${files[@]}"; do
if [ ! -f "$file" ]; then
echo "LINK GATE: \"$file\" is not a file" >&2
status=1
continue
fi

# Only the packaged README may not use relative links.
absolute_only=no
if [ "${file##*/}" = "README.md" ]; then
absolute_only=yes
fi

checked=0
while IFS= read -r link; do
[ -z "$link" ] && continue
checked=$((checked + 1))
case "$link" in
'' | '#'* | mailto:*)
;;
http://* | https://*)
case "$link" in
"$base"*)
path=${link#"$base"}
path=${path%%#*}
if [ ! -e "$root/$path" ]; then
echo "$file LINK: \"$path\" is linked but not in the repository" >&2
status=1
fi
;;
*/github.com/vyncint/*)
rest=${link#*github.com/vyncint/}
repo=${rest%%[/?#]*}
known=no
for candidate in $known_repos; do
if [ "$repo" = "$candidate" ]; then
known=yes
fi
done
if [ "$known" = no ]; then
echo "$file LINK: github.com/vyncint/$repo is not a repository this gate knows" >&2
echo " If the link is a typo, fix it. If the repository is real and newly" >&2
echo " linked, add it to known_repos in $(basename "$0")." >&2
status=1
fi
;;
esac
;;
*)
if [ "$absolute_only" = yes ]; then
echo "README LINK: relative target \"$link\" — crates.io rewrites it against" >&2
echo " crates/termlens/, where it does not exist. Use ${base}${link}" >&2
status=1
else
path=${link%%#*}
path=${path%%\?*}
if [ ! -e "$root/$path" ]; then
echo "$file LINK: \"$link\" is linked but not in the repository" >&2
status=1
fi
fi
;;
esac
done <<<"$(grep -oE '\]\([^)]+\)' "$file" | sed -E 's/^\]\(//; s/\)$//' | sort -u || true)"

total=$((total + checked))
echo "$file: $checked distinct link target(s)"
done
if [ "$missing" -gt 0 ]; then
status=1
fi

if [ "$status" -eq 0 ]; then
echo "readme links: $checked absolute link(s), every target present, none relative"
echo "markdown links: $total target(s) across ${#files[@]} file(s), every in-repo target present"
fi
exit "$status"
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ jobs:
# a relative link against crates/termlens/ rather than the repo root.
# Every relative link on the 0.10.2 page was a 404 and nothing here
# could see it, because the same file renders correctly on GitHub.
- run: .github/scripts/check-readme-links.sh
# CONTRIBUTING is the file a first-time contributor reads, and it is the
# one most likely to accumulate link rot — it points at scripts and
# workflows that get renamed. It is not packaged, so it may keep its
# relative links; what it may not keep is a target that is gone (#352).
- run: .github/scripts/check-readme-links.sh README.md CONTRIBUTING.md
# The screens of whatever failed, in the step summary (#251). The
# suite dogfoods the action with the CLI from this tree.
- uses: ./.github/actions/report
Expand Down Expand Up @@ -178,6 +182,10 @@ jobs:
with:
persist-credentials: false
- run: .github/scripts/check-ci-gates-listed.sh
# Proves the link gate can fail before it is trusted to pass (#352): a
# misspelled org repository and a deleted in-repo target are both
# asserted to go red, so today's green run means something.
- run: tools/link-gate-selftest/run.sh

msrv:
name: msrv
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ cargo test -p termlens --no-default-features --features serde
cargo test --workspace # default features
cargo build -p termlens-cli # then the CLI's documented exit codes:
.github/scripts/check-cli-contract.sh target/debug/termlens
.github/scripts/check-readme-links.sh README.md CONTRIBUTING.md # every in-repo link target exists; README.md alone may not use relative ones
RUSTDOCFLAGS='-D warnings' cargo doc --no-deps
RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --all-features
.github/scripts/check-candidate-statement.sh # README, CHANGELOG and STABILITY state the candidate in the same words
Expand All @@ -58,6 +59,7 @@ cargo +1.85 check -p termlens --all-features --all-targets --locked #
cargo +1.85 check -p termlens --no-default-features --all-targets --locked
cargo clippy --workspace --all-targets --all-features --target x86_64-pc-windows-msvc -- -D warnings # the Windows build, from any host: `rustup target add x86_64-pc-windows-msvc` once
tools/semver-gate-selftest/run.sh # the semver gate can fail (cargo install cargo-semver-checks)…
tools/link-gate-selftest/run.sh # …and so can the link gate, with no extra tooling
.github/scripts/check-semver.sh 0.11.0 # …and the public API is compatible with the last published release
```

Expand Down
12 changes: 12 additions & 0 deletions tools/link-gate-selftest/contributing-good/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Fixture: the shapes CONTRIBUTING.md legitimately uses

Not packaged, so relative links are fine — as long as the target is here.

- [the agent brief](AGENTS.md)
- [releasing](docs/RELEASING.md)
- [the workflow](.github/workflows/ci.yml)
- [a fragment](#5-developer-certificate-of-origin-dco)
- [another project](https://github.com/vyncint/mossaic)
- [this project's demo](https://github.com/vyncint/termlens-demo)
- [a label](https://github.com/vyncint/termlens/labels/good%20first%20issue)
- [an external site](https://developercertificate.org)
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Fixture: a relative link to a target that is not in the repository

- [the agent brief](AGENTS.md)
- [a document that was renamed away](docs/NOT-A-FILE.md)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Fixture: the #351 typo — one letter short of the repository name

- [the good first issues](https://github.com/vyncint/temlens/labels/good%20first%20issue)
3 changes: 3 additions & 0 deletions tools/link-gate-selftest/readme-missing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Fixture: the packaged README with an absolute target that is gone

- [the design notes](https://github.com/vyncint/termlens/blob/main/docs/NOT-A-FILE.md)
6 changes: 6 additions & 0 deletions tools/link-gate-selftest/readme-relative/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Fixture: the packaged README with a relative target

crates.io renders this file against `crates/termlens/`, so the link below is a
404 on the crate page even though it resolves on GitHub.

- [the design notes](docs/DESIGN.md)
52 changes: 52 additions & 0 deletions tools/link-gate-selftest/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Proves the markdown link gate can fail, before it is trusted to pass (#352).
#
# Each fixture models one way a link rots, and the gate is asserted to notice:
#
# contributing-good/ what CONTRIBUTING.md legitimately does — relative
# links, an org link, a fragment, an external site
# contributing-missing/ a relative link to a target that was renamed away
# contributing-typo-org/ #351's `…/vyncint/temlens/…`, one letter short
# readme-relative/ the packaged README with a relative target, which
# crates.io resolves against the crate directory
# readme-missing/ the packaged README with a dead absolute target
#
# The first and the last assertions are the ones that keep the gate honest: a
# fixture that must pass, and the repository's own two files, which must still
# pass. Usage: tools/link-gate-selftest/run.sh
set -euo pipefail

cd "$(dirname "${BASH_SOURCE[0]}")"
root="$(cd ../.. && pwd)"
gate="$root/.github/scripts/check-readme-links.sh"
status=0

# `<label> <expected: zero|nonzero> <file ...>`
expect() {
label=$1
want=$2
shift 2
got=0
"$gate" "$@" > out.log 2>&1 || got=$?
case "$want:$got" in
zero:0 | nonzero:[1-9]*)
printf ' ok %-58s exit %s\n' "$label" "$got" ;;
*)
printf ' FAIL %-58s exit %s, expected %s\n' "$label" "$got" "$want" >&2
sed 's/^/ /' out.log >&2
status=1 ;;
esac
}

expect "CONTRIBUTING.md: relative links, org link, fragment, external" zero contributing-good/CONTRIBUTING.md
expect "CONTRIBUTING.md: a deleted in-repo target fails" nonzero contributing-missing/CONTRIBUTING.md
expect "CONTRIBUTING.md: a misspelled org repository fails (#351)" nonzero contributing-typo-org/CONTRIBUTING.md
expect "README.md: a relative target fails (crates.io rewrites it)" nonzero readme-relative/README.md
expect "README.md: a missing absolute target fails" nonzero readme-missing/README.md
expect "this repository's own two files pass" zero "$root/README.md" "$root/CONTRIBUTING.md"

rm -f out.log
if [ "$status" -eq 0 ]; then
echo "link gate selftest: 6 expectation(s), the gate fails when it should"
fi
exit "$status"