From 4e375161833be96e6214c2dae3912a018de11820 Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Sat, 5 Sep 2026 02:17:04 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=E2=9C=A8=EF=BC=9Awatc?= =?UTF-8?q?h=20the=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. What the check answers is whether a reader can follow a link, which is the only question its own repository can settle. A 404 stands, with two exceptions and no others: the page is one of the two GitHub hides from anonymous visitors -- the stargazer and watcher lists -- and the repository is provably public. Being public is not on its own an excuse; a page that is merely absent from a public repository is still a page a reader cannot open, and treating visibility as the verdict would have hidden exactly that. Which pages those are was checked rather than assumed. Against a public repository the root, network members, contributor graphs, pulse, forks, activity, branches, tags, issues and pull requests all answer 200, and an address that does not exist answers 404 as it should. Nothing claims a repository was deleted: a 404 from the API is equally what a private repository returns to a caller that cannot see it, and the token a workflow is handed reaches only the repository it runs in. Nor is an API that declines to answer read as a no -- rate limited or unwell, it returns neither yes nor no, and turning somebody else's bad afternoon into a broken-link report is how a check earns its mute. A HEAD is never allowed to condemn a link either. Some hosts answer it from a route table and the GET from the content, so every unsuccessful HEAD is retried before anything is concluded. Reading the links out of prose moved into a module of its own with tests, because a bracket is harder than it looks: `…/Function_(maths)` is a real address and `[F](…/Function_(maths))` ends with one bracket that is not. Counting settles it, and seven cases hold it there. 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/shared/links.mts | 74 +++++++++ build/shared/links.test.mts | 60 +++++++ build/tasks/check-links.mts | 275 +++++++++++++++++++++++++++++++++ package-scripts.yml | 3 + package.json | 1 + 6 files changed, 508 insertions(+) create mode 100644 .github/workflows/link-rot.yml create mode 100644 build/shared/links.mts create mode 100644 build/shared/links.test.mts 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/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..268e5b8c --- /dev/null +++ b/build/tasks/check-links.mts @@ -0,0 +1,275 @@ +/** + * @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 } 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/', + ]); + 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" }, From 99ea56be7a1e9fce3ea6d99d731d0d10e21e41a5 Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Mon, 7 Sep 2026 04:34:35 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=F0=9F=94=A7=EF=BC=9Al?= =?UTF-8?q?et=20the=20link=20check=20actually=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This never ran. The workflow said the task "reads only what node ships with, so there is nothing to install", and it does not: check-links imports `glob` from build/utils, which imports @isaacs/catcher and @yarnpkg/shell. With no install step the job died at that import with ERR_MODULE_NOT_FOUND. Worse than a red X, because of what the exit code then meant. node exits 1 on an uncaught exception and `DEAD` is also 1, so the failure read as a dead link, and the next step filed the weekly issue with an empty body. A check that cries wolf is the one thing this was written not to be. Two changes, either of which would have caught it. The dependencies are installed, the way every other workflow here installs them. And the exit code is now believed only when it is one of the four the task can produce and the report is not empty -- the task prints its summary line before any finding, so an empty report means it did not get that far. Anything else fails the job loudly and files nothing. check-links also gains the `matched` guard af2e821 gave the verify tasks while this branch was open. It discovers its own files, so a glob that stopped matching would have reported zero links and nothing wrong with any of them. Run end to end here for the first time: 71 links, exit 3, a 953-byte report. It found two that are gone -- both anchors on OpenINF/wg-a-team, from doc/adr/0004 -- and correctly held back the rate-limited and login-walled ones as unchecked rather than dead, including the stargazers page the GitHub special case is there for. Those two dead links are left for the issue this will file. Signed-off-by: Derek Lewis Assisted-by: Claude-Code:claude-opus-5 --- .github/workflows/link-rot.yml | 37 ++++++++++++++++++++++++++++++---- build/tasks/check-links.mts | 8 +++++++- project-terms.txt | 2 ++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/.github/workflows/link-rot.yml b/.github/workflows/link-rot.yml index b4e5ceb9..2eb9bc3f 100644 --- a/.github/workflows/link-rot.yml +++ b/.github/workflows/link-rot.yml @@ -41,8 +41,21 @@ jobs: 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. + # 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: @@ -52,14 +65,30 @@ jobs: # 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:-0} & 1) != 0 ))" >> "$GITHUB_OUTPUT" - echo "unchecked=$(( (${code:-0} & 2) != 0 ))" >> "$GITHUB_OUTPUT" + 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: diff --git a/build/tasks/check-links.mts b/build/tasks/check-links.mts index 268e5b8c..b172d9e0 100644 --- a/build/tasks/check-links.mts +++ b/build/tasks/check-links.mts @@ -17,7 +17,7 @@ import { readFile } from 'node:fs/promises'; import { urlsIn } from '@openinf/.github/build/links'; -import { glob } from '@openinf/.github/build/utils'; +import { glob, matched } from '@openinf/.github/build/utils'; /** How long to wait on a host before giving up, in milliseconds. */ const TIMEOUT = 20_000; @@ -120,6 +120,12 @@ async function collect(): Promise { '!**/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) { 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