diff --git a/.github/workflows/link-rot.yml b/.github/workflows/link-rot.yml new file mode 100644 index 00000000..2eb9bc3f --- /dev/null +++ b/.github/workflows/link-rot.yml @@ -0,0 +1,124 @@ +# 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 imports `glob` from build/utils, which imports + # @isaacs/catcher and @yarnpkg/shell, so the dependencies have to be + # here. This step said they did not and there was nothing to install; + # the job then died at the import with ERR_MODULE_NOT_FOUND, and because + # a non-zero exit sets the `dead` bit below, it filed a report with an + # empty body rather than failing visibly. + - name: Install + run: | + # No corepack: node is unbundling it, and pnpm reads + # `packageManager` and fetches that version itself. Installing the + # named one rather than the newest saves fetching pnpm twice. + npm install --global "pnpm@$(node -p "require('./package.json').packageManager.replace('pnpm@','').split('+')[0]")" + pnpm --version + pnpm install + + - 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: | + code=0 + node build/tasks/check-links.mts > report.md || code=$? + cat report.md + + # 0 to 3 is the whole protocol -- bit 1 for a link that is gone, bit + # 2 for one that could not be reached. Anything above that is the + # task failing rather than reporting, and so is an empty report, + # since it prints its summary line before any finding. + # + # Checked because the two are otherwise the same answer: node exits + # 1 on an uncaught exception and `DEAD` is also 1, so a task that + # died on its first import read as a dead link and filed a report + # with nothing in it. Failing loudly here is what that should have + # done. + if [ "${code}" -gt 3 ] || [ ! -s report.md ]; then + echo "::error title=The link check did not run::It exited ${code}" + exit 1 + fi + + # 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 & 1) != 0 ))" >> "$GITHUB_OUTPUT" + echo "unchecked=$(( (code & 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/shared/links.mts b/build/shared/links.mts new file mode 100644 index 00000000..2c713a5a --- /dev/null +++ b/build/shared/links.mts @@ -0,0 +1,74 @@ +/** + * @file Finding the links in a piece of prose. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/shared/links + */ + +/** Where a URL starts, and everything a URL may contain. */ +const URL_PATTERN = /https?:\/\/[^\s<>"'`]+/g; + +/** Punctuation that ends a sentence rather than an address. */ +const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', '!', '?']); + +/** Closers that may belong to the URL or to the markup around it. */ +const CLOSERS: Record = { ')': '(', ']': '[' }; + +/** + * Counts a character in a string. + * @param {string} text Where to count. + * @param {string} character What to count. + * @returns {number} How many there are. + */ +const count = (text: string, character: string) => + [...text].filter((one) => one === character).length; + +/** + * Trims what belongs to the sentence rather than to the address. + * + * A closing bracket is the hard part, because a URL may legitimately contain + * one: `…/Function_(mathematics)` is a real address, and in Markdown it is + * also written `[…](…/Function_(mathematics))` where the last `)` closes the + * link instead. Counting settles it -- a closer is kept only while something + * inside the URL opened it. + * @param {string} url The candidate, as matched. + * @returns {string} The address without the punctuation around it. + */ +function trim(url: string) { + let end = url.length; + + for (let again = true; again && end > 0; ) { + const last = url[end - 1] ?? ''; + + again = false; + + if (TRAILING_PUNCTUATION.has(last)) { + end -= 1; + again = true; + continue; + } + + const opener = CLOSERS[last]; + + if ( + opener !== undefined && + count(url.slice(0, end), last) > count(url.slice(0, end), opener) + ) { + end -= 1; + again = true; + } + } + + return url.slice(0, end); +} + +/** + * Reads every http(s) link out of a piece of prose. + * @param {string} text What to read. + * @returns {string[]} The addresses, in the order they appear, with duplicates kept. + */ +export function urlsIn(text: string) { + return [...text.matchAll(URL_PATTERN)] + .map((match) => trim(match[0])) + .filter((url) => /^https?:\/\/\S/.test(url)); +} diff --git a/build/shared/links.test.mts b/build/shared/links.test.mts new file mode 100644 index 00000000..5ed68eb9 --- /dev/null +++ b/build/shared/links.test.mts @@ -0,0 +1,60 @@ +/** + * @file Tests for reading links out of prose. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/shared/links.test + */ + +import { deepStrictEqual } from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { urlsIn } from '@openinf/.github/build/links'; + +describe('urlsIn', () => { + test('keeps a bracket the address opened', () => { + deepStrictEqual( + urlsIn('See https://example.com/Function_(mathematics) now.'), + ['https://example.com/Function_(mathematics)'] + ); + }); + + test('drops the bracket markdown closed', () => { + deepStrictEqual( + urlsIn('[F](https://example.com/Function_(mathematics)) and on.'), + ['https://example.com/Function_(mathematics)'] + ); + }); + + test('drops a plain markdown closer', () => { + deepStrictEqual(urlsIn('[a](https://example.com/a)'), [ + 'https://example.com/a', + ]); + }); + + test('leaves an angle-bracketed autolink alone', () => { + deepStrictEqual(urlsIn(''), [ + 'https://example.com/a', + ]); + }); + + test('drops sentence punctuation', () => { + deepStrictEqual(urlsIn('Go to https://example.com/a.'), [ + 'https://example.com/a', + ]); + deepStrictEqual(urlsIn('https://example.com/a, then b.'), [ + 'https://example.com/a', + ]); + }); + + test('drops a reference-definition closer', () => { + deepStrictEqual(urlsIn('[x]: https://example.com/a]'), [ + 'https://example.com/a', + ]); + }); + + test('finds several, in order', () => { + deepStrictEqual(urlsIn('a https://one.example b https://two.example c'), [ + 'https://one.example', + 'https://two.example', + ]); + }); +}); diff --git a/build/tasks/check-links.mts b/build/tasks/check-links.mts new file mode 100644 index 00000000..b172d9e0 --- /dev/null +++ b/build/tasks/check-links.mts @@ -0,0 +1,281 @@ +/** + * @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 { urlsIn } from '@openinf/.github/build/links'; +import { glob, matched } 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)', +}; + +/** + * The `github.com` paths that answer 404 to anybody not signed in, even for a + * public repository. Checked rather than assumed: of the pages tried against + * a public repository -- the repository root, network members, contributor + * graphs, pulse, forks, activity, branches, tags, issues and pull requests -- + * only these two are hidden, and a path that simply does not exist answers + * 404 as it should. So the exception is these two and nothing else: a 404 on + * any other page is a page a reader cannot open. + */ +const SIGN_IN_ONLY = /\/(?:stargazers|watchers)\/?$/; + +/** Where a repository is named in a GitHub URL. */ +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 whether a repository is provably public. + * + * Three answers, because two would lose the difference that matters. `true` + * and `false` are what the API said; `undefined` is the API declining to say + * -- rate limited, unwell, or unreachable -- and treating that as `false` + * would turn somebody else's bad afternoon into a report of a broken link. + * @param {string} owner The account it belongs to. + * @param {string} repo Its name. + * @returns {Promise} Whether it is public, or undefined if unanswered. + */ +async function repoIsPublic(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), + } + ); + + // Deleted, or private and not ours to see. Either way a reader following + // this link arrives nowhere, which is what the report is about. + if (response.status === 404) return false; + + if (!response.ok) return undefined; + + return ((await response.json()) as { private?: boolean }).private !== true; + } 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/', + ]); + + // The same guard the verify tasks carry. A glob that stopped matching would + // otherwise report zero links checked and nothing wrong with any of them, + // which is the one answer a check must never give. + if (!matched(files, '**/*.md')) return []; + + const found = new Map>(); + + for (const file of files) { + const text = await readFile(file, 'utf8'); + + for (const url of urlsIn(text)) { + 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, and + // that includes a 404: some hosts answer HEAD from a route table and + // GET from the content. Only a GET is allowed to condemn a link. + if (method === 'HEAD' && !response.ok) 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 few 404s that are not a reader's problem. Everything else + * stands: a link that does not open is a link that leads nowhere, whether the + * page was deleted, renamed, or was never theirs to see. + * @param {object} link The link and what the host said about it. + * @returns {Promise} The same, with the verdict softened only where it should be. + */ +async function judge(link: (typeof verdicts)[number]) { + if (!GONE.has(link.status)) return link; + + const found = link.url.match(REPO_URL)?.groups; + + // raw.githubusercontent serves public content or nothing, and a page that + // is not one of the two GitHub hides is a page a reader should have been + // able to open. Neither needs the API, which is also two fewer requests + // towards a rate limit. + if ( + found === undefined || + link.url.startsWith('https://raw.') || + !SIGN_IN_ONLY.test(new URL(link.url).pathname) + ) { + return link; + } + + const repo = `${found.owner}/${found.repo}`; + const isPublic = await repoIsPublic(found.owner ?? '', found.repo ?? ''); + + if (isPublic === undefined) { + return { + ...link, + reason: `HTTP 404 on a page GitHub hides from anonymous visitors, and the API would not say whether ${repo} is public`, + status: 0, + }; + } + + return isPublic + ? { + ...link, + reason: `HTTP 404 anonymously, but ${repo} is public and this is a page GitHub shows only to signed-in visitors`, + status: 0, + } + : link; +} + +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 6a930335..ea5e7b3b 100644 --- a/package-scripts.yml +++ b/package-scripts.yml @@ -11,6 +11,9 @@ scripts: js: node build/tasks/verify/verify-js.mts json: node build/tasks/verify/verify-json.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 diff --git a/package.json b/package.json index a544ee27..0c600c77 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "exports": { "./build/commit-message": "./build/shared/commit-message.mts", "./build/landing": "./build/shared/landing.mts", + "./build/links": "./build/shared/links.mts", "./build/pull-request": "./build/shared/pull-request.mts", "./build/utils": "./build/utils.mts" }, diff --git a/project-terms.txt b/project-terms.txt index 0698774e..d1701da5 100644 --- a/project-terms.txt +++ b/project-terms.txt @@ -44,6 +44,7 @@ hrefs htmlparser incentivizing Irulen +isaacs itemprop itemscope itemtype @@ -98,4 +99,5 @@ unconfigured unentitled unifiedjs windir +yarnpkg zindex