From 3e1b756dc73a7dc2516b73cffd838759a651d033 Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Sat, 5 Sep 2026 02:17:04 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=E2=9C=A8=EF=BC=9Awatch=20?= =?UTF-8?q?the=20prose=20for=20links=20that=20have=20rotted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Link rot has reached this repository twice now that anyone noticed: the Discord invites in #886, and every image at the top of the README, which had been a broken-image icon for long enough that nobody was seeing it any more. Nothing here looks for it. remark-validate-links reads links within the project and stops at its edge. Weekly rather than on every pull request, and outside verify/ for the reason `verify.pullRequest` is: it reaches the network, and a host that is slow or rate-limiting has nothing to do with the change being reviewed. It opens an issue rather than a pull request, because where a rotted link should point instead is a decision, and often the answer is to delete the sentence around it. What it will not do is cry wolf, which is the only way a check like this survives. A 404 is the only status read as gone; a rate limit, a login wall or a bad afternoon is reported separately as a question that could not be answered. GitHub is a special case both ways: it answers 404 for any page it will not show an anonymous client -- the stargazer list of nodejs/node is a 404 from here -- so a 404 there is settled against the API instead, and a repository that is merely private is not called deleted unless a token can tell the difference. Run against this branch it finds seven dead links and no false ones. Signed-off-by: Derek Lewis Assisted-by: Claude-Code:claude-opus-5 --- .github/workflows/link-rot.yml | 95 ++++++++++++ build/tasks/check-links.mts | 256 +++++++++++++++++++++++++++++++++ package-scripts.yml | 3 + 3 files changed, 354 insertions(+) create mode 100644 .github/workflows/link-rot.yml create mode 100644 build/tasks/check-links.mts diff --git a/.github/workflows/link-rot.yml b/.github/workflows/link-rot.yml new file mode 100644 index 00000000..b4e5ceb9 --- /dev/null +++ b/.github/workflows/link-rot.yml @@ -0,0 +1,95 @@ +# Watching the links in this project's prose for rot. +# +# Weekly rather than on every pull request, because this reaches the network: +# a host that is slow, rate-limiting or briefly down would otherwise fail +# changes that have nothing to do with it. The same reasoning that keeps +# `verify.links` out of the verify/ directory keeps it out of the pull request +# checks. +# +# It opens an issue rather than a pull request. A link that has rotted needs +# somebody to decide where it should point instead, which is not a thing to +# guess at, and the answer is often to delete the sentence around it. +# +# Actions are pinned by commit, never by tag. +name: Link rot + +on: + schedule: + # Thursday, clear of the other scheduled runs. + - cron: '0 5 * * 4' + workflow_dispatch: + +permissions: + contents: read + issues: write + +# A scheduled run and a hand-started one should not both file the same report. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + check: + name: Check links + runs-on: ubuntu-latest + steps: + - name: Check out project repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Node.js runtime + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: 'package.json' + # The task reads only what node ships with, so there is nothing to + # install and no lockfile to resolve before it can run. + - name: Look for rot + id: check + env: + # GitHub answers 404 for a page it will not show an anonymous + # client, so without this the task cannot tell a repository that was + # deleted from one that is merely private, and says so rather than + # guessing. The token settles it. + GITHUB_TOKEN: ${{ github.token }} + run: | + node build/tasks/check-links.mts > report.md || code=$? + cat report.md + + # Both bits are read: one link being dead says nothing about whether + # another was reachable, so neither answer is allowed to hide the + # other. + echo "dead=$(( (${code:-0} & 1) != 0 ))" >> "$GITHUB_OUTPUT" + echo "unchecked=$(( (${code:-0} & 2) != 0 ))" >> "$GITHUB_OUTPUT" + - name: Say so, once + if: steps.check.outputs.dead == '1' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TITLE: 🔗 a link in this project leads nowhere + run: | + # Matched against the open issues themselves rather than through + # search, which is an index and lags behind what was just written. + # One issue at a time: a weekly comment on a report nobody has acted + # on yet says nothing the report did not. + open=$(gh issue list --state open --limit 1000 --json number,title \ + --jq 'map(select(.title == env.TITLE)) | .[0].number // empty') + + if [ -n "$open" ]; then + echo "already reported in #${open}" + exit 0 + fi + + { + echo 'These were followed and did not arrive anywhere. Decide' + echo 'where each should point, or take the sentence out.' + echo + cat report.md + } > body.md + + gh issue create --title "$TITLE" --body-file body.md \ + --label '📖 Category: Documentation' + # Not a failure. A host that would not answer is not this project's + # problem to fix, and failing here weekly would teach everyone to ignore + # a workflow that is usually right. + - name: Note what could not be checked + if: always() && steps.check.outputs.unchecked == '1' + run: echo '::notice::some links could not be checked; see the log' diff --git a/build/tasks/check-links.mts b/build/tasks/check-links.mts new file mode 100644 index 00000000..c7d884fe --- /dev/null +++ b/build/tasks/check-links.mts @@ -0,0 +1,256 @@ +/** + * @file Check that the links in this project's prose still lead somewhere. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/tasks/check-links + * + * Outside `verify/` on purpose, the way `verify-pull-request.mts` is: it + * reaches the network, and every task in that directory runs on every pull + * request. A host that is slow, rate-limiting or briefly down would otherwise + * fail changes that have nothing to do with it. + * + * The exit code carries both answers at once, since one link being dead says + * nothing about whether another was reachable: bit 1 is set when a link is + * gone, bit 2 when one could not be checked. A dead link is a thing to act on + * and a host that would not answer is not, so neither hides the other. + */ + +import { readFile } from 'node:fs/promises'; +import { glob } from '@openinf/.github/build/utils'; + +/** How long to wait on a host before giving up, in milliseconds. */ +const TIMEOUT = 20_000; + +/** How many requests to have in flight at once. */ +const CONCURRENCY = 8; + +/** Bits of the exit code. Both can be set; neither masks the other. */ +const ALIVE = 0; +const DEAD = 1; +const UNCHECKED = 2; + +/** + * Only these mean the link is gone. Everything else that is not a success -- + * a rate limit, a login wall, a host having a bad afternoon -- is a question + * this task could not answer, and reporting it as rot is how a check like + * this teaches people to ignore it. + */ +const GONE = new Set([404, 410]); + +/** Sent because a bare fetch is what several hosts refuse outright. */ +const HEADERS = { + accept: 'text/html,application/xhtml+xml,*/*;q=0.8', + 'user-agent': + 'Mozilla/5.0 (compatible; OpenINF-link-check; +https://github.com/OpenINF/.github)', +}; + +/** + * GitHub answers 404 for a page it will not show an anonymous client -- the + * stargazer list of nodejs/node, with its hundred and twenty thousand stars, + * is a 404 from here -- so a 404 on a `github.com` page is not evidence the + * link is dead. What can be settled is whether the repository behind it still + * exists, which the API will say, and a repository that is gone takes every + * link into it with it. + * + * `raw.githubusercontent.com` has no such login wall: a 404 there means the + * file is not being served, which is exactly the thing worth reporting. + */ +const REPO_URL = + /^https:\/\/(?:github\.com|raw\.githubusercontent\.com)\/(?[^/]+)\/(?[^/#?]+)/; + +/** + * A token widens what the API will answer and lifts the anonymous rate limit. + * Sent to api.github.com and nowhere else: a credential handed to whichever + * host a link happens to name is a credential given away. + */ +const TOKEN = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''; + +/** + * Asks the API whether a repository still exists. + * @param {string} owner The account it belongs to. + * @param {string} repo Its name. + * @returns {Promise} Whether it is there, or undefined if the API would not say. + */ +async function repoExists(owner: string, repo: string) { + try { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo.replace(/\.git$/, '')}`, + { + headers: { + accept: 'application/vnd.github+json', + ...(TOKEN === '' ? {} : { authorization: `Bearer ${TOKEN}` }), + }, + signal: AbortSignal.timeout(TIMEOUT), + } + ); + + // Anonymously, a private repository is a 404 and so is a deleted one. + // Saying "gone" of a repository that is merely not ours to see is the + // kind of wrong answer that gets a check like this switched off, so + // without a token the question stays open. + if (response.status === 404) return TOKEN === '' ? undefined : false; + + return response.ok ? true : undefined; + } catch { + return undefined; + } +} + +/** One link, and the files that point at it. */ +type Link = { url: string; files: string[] }; + +/** + * Reads every http(s) link out of the project's prose. + * @returns {Promise} Each distinct URL, with the files naming it. + */ +async function collect(): Promise { + const files = await glob([ + '**/*.md', + '!doc/_site/', + '!lib/', + '!node_modules/', + '!vendor/', + '!**/COPYING.md', + '!LICENSE/', + ]); + const found = new Map>(); + + for (const file of files) { + const text = await readFile(file, 'utf8'); + + // Trailing punctuation belongs to the sentence rather than to the URL, + // and a closing bracket to the markdown around it. + for (const match of text.matchAll(/https?:\/\/[^\s<>"')\]]+/g)) { + const url = match[0].replace(/[.,;:]+$/, ''); + + found.set(url, (found.get(url) ?? new Set()).add(file)); + } + } + + return [...found] + .map(([url, where]) => ({ url, files: [...where].sort() })) + .sort((one, other) => one.url.localeCompare(other.url)); +} + +/** + * Asks a host whether a link still leads somewhere. HEAD first, because it + * costs the host a header rather than a page; some serve it wrongly or not at + * all, and those get a GET before any conclusion is drawn. + * @param {string} url The link to ask about. + * @returns {Promise<{ status: number; reason: string }>} The verdict, `status` 0 when nothing answered. + */ +async function probe(url: string) { + for (const method of ['HEAD', 'GET'] as const) { + try { + const response = await fetch(url, { + headers: HEADERS, + method, + redirect: 'follow', + signal: AbortSignal.timeout(TIMEOUT), + }); + + // A HEAD that is refused says nothing about the page behind it. + if (method === 'HEAD' && !response.ok && !GONE.has(response.status)) { + continue; + } + + return { reason: `HTTP ${response.status}`, status: response.status }; + } catch (error) { + if (method === 'GET') { + return { + reason: error instanceof Error ? error.message : String(error), + status: 0, + }; + } + } + } + + return { reason: 'no answer', status: 0 }; +} + +/** + * Runs `work` over `items`, a few at a time. + * @param {T[]} items What to work through. + * @param {(item: T) => Promise} work What to do with each. + * @returns {Promise} The results, in the order the items were given. + */ +async function inBatches(items: T[], work: (item: T) => Promise) { + const results: R[] = []; + + for (let index = 0; index < items.length; index += CONCURRENCY) { + results.push( + ...(await Promise.all(items.slice(index, index + CONCURRENCY).map(work))) + ); + } + + return results; +} + +const links = await collect(); +const verdicts = await inBatches(links, async (link) => ({ + ...link, + ...(await probe(link.url)), +})); + +/** + * Settles the 404s that `github.com` hands out for pages it will not show. + * A repository that is gone makes the link dead; one that is still there + * leaves the question open rather than answered. + */ +async function judge(link: (typeof verdicts)[number]) { + if (!GONE.has(link.status)) return link; + + const found = link.url.match(REPO_URL)?.groups; + + if (found === undefined || link.url.startsWith('https://raw.')) return link; + + const exists = await repoExists(found.owner ?? '', found.repo ?? ''); + + if (exists === false) { + return { ...link, reason: `${found.owner}/${found.repo} no longer exists` }; + } + + return { + ...link, + reason: + exists === true + ? `HTTP 404 anonymously, though ${found.owner}/${found.repo} exists -- a page GitHub shows only to signed-in visitors` + : `HTTP 404, and the API would not say whether ${found.owner}/${found.repo} still exists (a token would settle it)`, + status: 0, + }; +} + +const judged = await inBatches(verdicts, judge); +const dead = judged.filter((link) => GONE.has(link.status)); +const unchecked = judged.filter( + (link) => + !(GONE.has(link.status) || (link.status >= 200 && link.status < 400)) +); + +console.log( + `Checked ${links.length} link${links.length === 1 ? '' : 's'} across the project's prose.` +); + +// Everything goes to stdout, including what went wrong: whatever runs this +// keeps only that, and a reason written anywhere else is a reason lost. +if (dead.length > 0) { + console.log(''); + console.log('These lead nowhere:'); + for (const link of dead) { + console.log(`- ${link.url} — ${link.reason}`); + for (const file of link.files) console.log(` - \`${file}\``); + } +} + +if (unchecked.length > 0) { + console.log(''); + console.log('These could not be checked, which is not the same as gone:'); + for (const link of unchecked) { + console.log(`- ${link.url} — ${link.reason}`); + } +} + +process.exitCode = + ALIVE | + (dead.length > 0 ? DEAD : ALIVE) | + (unchecked.length > 0 ? UNCHECKED : ALIVE); diff --git a/package-scripts.yml b/package-scripts.yml index 89849cd9..3c8a06ad 100644 --- a/package-scripts.yml +++ b/package-scripts.yml @@ -16,6 +16,9 @@ scripts: json: node build/tasks/verify/verify-json.mts liquid: node build/tasks/verify/verify-liquid.mts md: node build/tasks/verify/verify-md.mts + # Outside verify/ for the same reason: this one reaches the network, and + # a host that is slow or rate-limiting would fail unrelated changes. + links: node build/tasks/check-links.mts # Outside verify/ on purpose: it needs a pull request in the environment, # and verify.all runs everything in that directory. pullRequest: node build/tasks/verify-pull-request.mts