From b6b7a434ef1517975044e26d2ea70de7b1d8154e Mon Sep 17 00:00:00 2001 From: jarik2014 <46443045+jarik2014@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:59:51 +0000 Subject: [PATCH 1/2] fix(ci): check CONTRIBUTING links, not just the README's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-readme-links.sh read README.md and nothing else, so a dead link in CONTRIBUTING.md — the file a first-time contributor reads — passed all sixteen checks. Two fixtures make the gate fail on the shapes that slipped through: a misspelled org repository (#351) and a renamed in-repo target. The README's absolute-only rule is crates.io's and stays on README.md alone: CONTRIBUTING is not packaged and legitimately links AGENTS.md, docs/RELEASING.md and .github/workflows/ci.yml relatively, so pointing the old script at it would have failed on all three. Both files now get the rule that generalises — an in-repo target must exist — and a link naming no in-repo path is checked against the org list, offline. Signed-off-by: jarik2014 <46443045+jarik2014@users.noreply.github.com> --- .github/scripts/check-readme-links.sh | 149 +++++++++++++----- .github/workflows/ci.yml | 10 +- CONTRIBUTING.md | 2 + .../contributing-good/CONTRIBUTING.md | 11 ++ .../contributing-missing/CONTRIBUTING.md | 4 + .../contributing-typo-org/CONTRIBUTING.md | 3 + .../readme-missing/README.md | 3 + .../readme-relative/README.md | 6 + tools/link-gate-selftest/run.sh | 52 ++++++ 9 files changed, 197 insertions(+), 43 deletions(-) create mode 100644 tools/link-gate-selftest/contributing-good/CONTRIBUTING.md create mode 100644 tools/link-gate-selftest/contributing-missing/CONTRIBUTING.md create mode 100644 tools/link-gate-selftest/contributing-typo-org/CONTRIBUTING.md create mode 100644 tools/link-gate-selftest/readme-missing/README.md create mode 100644 tools/link-gate-selftest/readme-relative/README.md create mode 100755 tools/link-gate-selftest/run.sh diff --git a/.github/scripts/check-readme-links.sh b/.github/scripts/check-readme-links.sh index f6e692c..28c8e1e 100755 --- a/.github/scripts/check-readme-links.sh +++ b/.github/scripts/check-readme-links.sh @@ -1,58 +1,123 @@ #!/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 org 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 +# Repositories that exist in the vyncint org: the four that share this +# contributor pattern. A `github.com/vyncint/` link is a typo or +# a repository that moved, and either way it is a dead end for a reader. +known_repos="launchbound mossaic reconverge termlens" + +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 names no repository in the org" >&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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5229139..6a07d31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e2f35f..5664caf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 @@ -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 ``` diff --git a/tools/link-gate-selftest/contributing-good/CONTRIBUTING.md b/tools/link-gate-selftest/contributing-good/CONTRIBUTING.md new file mode 100644 index 0000000..a68362b --- /dev/null +++ b/tools/link-gate-selftest/contributing-good/CONTRIBUTING.md @@ -0,0 +1,11 @@ +# 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) +- [a label](https://github.com/vyncint/termlens/labels/good%20first%20issue) +- [an external site](https://developercertificate.org) diff --git a/tools/link-gate-selftest/contributing-missing/CONTRIBUTING.md b/tools/link-gate-selftest/contributing-missing/CONTRIBUTING.md new file mode 100644 index 0000000..09609ca --- /dev/null +++ b/tools/link-gate-selftest/contributing-missing/CONTRIBUTING.md @@ -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) diff --git a/tools/link-gate-selftest/contributing-typo-org/CONTRIBUTING.md b/tools/link-gate-selftest/contributing-typo-org/CONTRIBUTING.md new file mode 100644 index 0000000..c833282 --- /dev/null +++ b/tools/link-gate-selftest/contributing-typo-org/CONTRIBUTING.md @@ -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) diff --git a/tools/link-gate-selftest/readme-missing/README.md b/tools/link-gate-selftest/readme-missing/README.md new file mode 100644 index 0000000..7b30d88 --- /dev/null +++ b/tools/link-gate-selftest/readme-missing/README.md @@ -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) diff --git a/tools/link-gate-selftest/readme-relative/README.md b/tools/link-gate-selftest/readme-relative/README.md new file mode 100644 index 0000000..5172e6c --- /dev/null +++ b/tools/link-gate-selftest/readme-relative/README.md @@ -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) diff --git a/tools/link-gate-selftest/run.sh b/tools/link-gate-selftest/run.sh new file mode 100755 index 0000000..126c8c1 --- /dev/null +++ b/tools/link-gate-selftest/run.sh @@ -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 + +# `