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
124 changes: 124 additions & 0 deletions .github/workflows/link-rot.yml
Original file line number Diff line number Diff line change
@@ -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'
74 changes: 74 additions & 0 deletions build/shared/links.mts
Original file line number Diff line number Diff line change
@@ -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<string, string> = { ')': '(', ']': '[' };

/**
* 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));
}
60 changes: 60 additions & 0 deletions build/shared/links.test.mts
Original file line number Diff line number Diff line change
@@ -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>'), [
'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',
]);
});
});
Loading
Loading