Link checking¶
Added in version 0.34.0.
ubc build linkcheck requests every external URL your project references
and grades the answer.
It is built to be run as a blocking CI step, which is the part that usually goes wrong: a checker that is slow, that fails on somebody else’s outage, or that reports a bot-block as a dead link gets turned off, and a turned-off checker finds nothing. Three design choices follow from that, and they are worth knowing before you read the options:
Every occurrence is reported. A URL is requested once however many times it is written, but a dead link used in twelve files is twelve locations to fix, each with its own
file:line:column— or as much of one as is actually known, never a guessed position.Verdicts are richer than pass/fail. A
403from a bot-blocker, somebody else’s5xxoutage, a timeout and a permanent redirect are four different things with four different severities. By default only a verified-dead link fails the command.Results are cached, failures are not. A second run over an unchanged project makes no network requests at all; a link that has been fixed is noticed on the very next run. The one exception is a missing anchor, which is stored beside the page’s own result and expires with it — see Anchors.
Minimal example¶
[linkcheck]
deny = "error" # gate bar: none | info | warning | error
ignore = ["^https://internal\\.example/"]
timeout = 30.0 # seconds per HTTP request
workers = 8
cache_days = 5 # 0 = always re-check, negative = never expire
offline = false # never fetch; serve stored results
rate_limit_ceiling = 300.0 # longest honoured Retry-After, in seconds
max_redirects = 10
user_agent = "" # "" = ubc/<version>
allow_private = false # permit intranet / loopback targets
[linkcheck.allowed_redirects]
"^https://old\\.example/" = "^https://new\\.example/"
[linkcheck.hosts."slow.example.com"]
max_concurrent = 1 # default: 2
min_interval_ms = 1000 # default: 250
[linkcheck.request_headers."https://api.example.com/"]
Accept = "application/json"
Authorization = "Bearer ${EXAMPLE_TOKEN}"
Running it¶
ubc build linkcheck # the project in the current directory
ubc build linkcheck docs/ # an explicit project root
ubc build linkcheck --show-links # print every finding, even on a pass
ubc build linkcheck --output-format json
ubc build linkcheck --output-format sarif
ubc build linkcheck --refresh # ignore stored results
ubc build linkcheck --fix-redirects --dry-run # what would be rewritten
ubc build linkcheck --fix-redirects # rewrite it
ubc build linkcheck --offline # serve stored results only
The command prints one summary line, and — on a failing run, or with
--show-links — the findings grouped by URL with every occurrence beneath
them, each headed by its severity and code (see Linting on the command line):
error[linkcheck.broken]
--> (reference)
docs/guide.rst:42:17
--> (reference)
docs/index.rst:8:3
https://example.test/gone: HTTP 404 Not Found
12 link(s) checked (9 from cache, 3 request(s)): 1 broken, 2 permanently redirected.
A failing run prints its detail without being asked, so you never have to re-run the command to learn what failed.
Note
ubc build linkcheck requires an active license — a commercial one, or the
automatic open-source allowance — however small the project.
There is no unlicensed free tier here, unlike the size-capped allowance the
indexing commands have; a run without a license is refused before any
indexing or network work, with exit code 1.
The cache dir | list | clean subcommands stay available without a
license: they manage local state and check nothing.
Verdict classes¶
Each class has its own linkcheck.* code and its own default severity,
which is what the deny bar compares against.
Code |
Meaning |
Severity |
|---|---|---|
|
Verified dead: DNS did not resolve, the connection was refused, TLS
failed, or the server answered |
error |
|
The page is live but the |
warning |
|
|
warning |
|
|
info |
|
Reachable, but the answer does not say whether the link works: |
warning |
|
|
warning |
|
The request exceeded its budget, after the retries its class earns. |
warning |
linkcheck.anchor_missing is a warning rather than an error, which is
the one severity in that table worth explaining.
A missing anchor leaves the reader on a live page at the wrong scroll position,
not on a dead one — and unlike a 404, it is a claim about a rendered page
this tool reconstructed from the bytes it chose to read.
Every way that claim can be wrong is closed by design except one: anchors that
a site injects with JavaScript, on a host with no
quirk entry.
That set is defined by other people’s frontend choices, so failing builds on it
by default is a promise this tool cannot keep.
--deny warning (or [linkcheck] deny) is one
line if you want the strict bar, and the default is due for review one release
after it ships.
Three further outcomes appear in the report and produce no finding:
workingThe URL answered successfully.
ignoredThe URL matched an ignore pattern and was never requested. It is still listed, so a suppression is visible rather than silent.
uncheckedThe URL uses a scheme this checker does not verify (
mailto:,ftp:,tel:, …), points at a private or loopback address whileallow_privateis off, or — in--offlinemode — has no stored result.
The linkcheck.* codes go through the ordinary
lint filters: [lint] ignore suppresses a whole class,
[lint] message-ignores narrows one to particular messages, and
[lint.per-file-ignores] suppresses classes for particular source files —
the last of these per occurrence, so a rule naming one directory leaves the same
URL’s other occurrences standing.
Suppressed findings appear in the summary line as N suppressed by [lint].
That number counts findings — a missing anchor is silenced in its own right,
so it is counted there even though summary.checked does not count it as a
URL, and the total can therefore exceed the number of links reported as checked.
Suppressing a page also hides its unverified and not_checked anchor
counts, on the same principle: a finding the project asked not to be told about
should not leave a number behind.
Options¶
deny¶
Type: string (default: "error")
The severity at or above which a finding fails the command:
none, info, warning or error.
The default means only a verified-dead link fails a build.
Redirects, bot-blocks, outages and timeouts are reported and do not.
--deny overrides this key for one run, and --max-warnings bounds the
warning count in addition — whichever bound is stricter wins.
The gate is a judgement on the report, not a refusal to produce one: the findings are printed either way, and only the exit code changes.
The two numbers count different things, deliberately.
--deny and --max-warnings count occurrences — one dead link written
in twelve files is twelve things to fix — while the summary line and
summary.counts in the JSON count URLs, because one URL was requested
once and earned one verdict.
So a run can report 1 broken and still exceed --max-warnings 5.
ignore¶
Type: array of strings (default: [])
Regular expressions matched against each full URL.
A URL matching any of them is reported as ignored and never requested.
Patterns are searched anywhere in the URL, so example\.com covers every
URL on that host; anchor with ^ or $ when you want a prefix or a whole
match.
The URL a pattern is matched against is the normalized one — the spelling
the checker requests, not the bytes in your document.
Normalization drops the #fragment, lowercases the scheme and host, and
removes a default port, so a pattern written with #section, an uppercase
host or an explicit :443 can never match.
Write the pattern the way the report prints the URL.
[linkcheck]
ignore = [
"^https://internal\\.example/", # a host only your VPN can reach
"linkedin\\.com", # a host that blocks every checker
]
To suppress findings for particular source files instead, use
[lint.per-file-ignores] with the linkcheck.* codes — the equivalent of
Sphinx’s linkcheck_exclude_documents, without a second knob to learn.
allowed_redirects¶
Type: table of string → string (default: {})
Redirects you expect, as from-pattern = to-pattern.
Both sides are regular expressions searched anywhere in the URL.
When a URL matching a key redirects to a URL matching that key’s value, the
result is reported as working instead of as a redirect.
[linkcheck.allowed_redirects]
# Our own docs moved; the redirect is intentional and permanent.
"^https://docs\\.old\\.example/" = "^https://docs\\.new\\.example/"
A redirect that only adds a trailing slash to a bare host, drops a default port or drops a fragment is never reported, so no entry is needed for those.
timeout¶
Type: float (default: 30.0)
Seconds allowed for each HTTP request. A value that is zero, negative or not a number falls back to the default and is reported as a configuration diagnostic.
workers¶
Type: integer (default: 8)
How many URLs are checked in parallel. 0 is treated as 1.
Per-host limits apply independently: at most 2 requests to any one host at a time, and at least 250 ms between two requests to it. Raising this therefore speeds up a project that references many hosts, not one that references a few.
cache_days¶
Type: integer (default: 5)
How long a stored result stays usable:
> 0— a result expires after this many days;0— always re-check;negative — never expire.
Freshness is applied when the cache is read, so changing this value does not discard anything. offline mode is the one exception: it serves expired results too, and reports how many.
offline¶
Type: boolean (default: false)
Never perform network requests: serve stored results, and report a URL with no
stored result as unchecked.
Useful for a network-restricted CI job with a pre-warmed cache.
Offline mode ignores cache_days and serves
expired results as well as fresh ones — a stale answer beats no answer when
there is no way to get a fresh one.
The run says how many it did that for: the summary line reads
N link(s) checked (M from cache, K expired, 0 request(s)), and
--output-format json carries the same number as summary.stale.
An offline run also reports the anchors it could not ask about, as
N anchor(s) not checked on the same line (see
the four states).
A job that wants “offline, but never on expired data” should gate on that number
being 0; the run itself does not refuse, because refusing is what an offline
mode exists not to do.
--offline turns this on for one run.
When the key is true, --refresh is refused rather than silently
ignored, and the message names the override to type.
rate_limit_ceiling¶
Type: float (default: 300.0)
The longest wait, in seconds, a rate-limited host can ask for and still be waited out.
A server answering 429 Too Many Requests with a longer Retry-After than
this — and a server that keeps deferring until the waits add up to more than
this — makes the URL report as blocked rather than holding the run open.
Both forms of Retry-After are understood: a number of seconds, and an
HTTP-date.
This is the knob that makes the command safe to run unattended. A value that is zero, negative or not a number falls back to the default rather than to “wait indefinitely”.
max_redirects¶
Type: integer (default: 10)
How many redirects one URL may go through before it is reported as broken.
user_agent¶
Type: string (default: "")
The User-Agent sent with each request.
Empty means ubc/<version>, which identifies the tool honestly, alongside a
browser-plausible Accept header.
Some sites refuse requests from any agent they do not recognise. Setting a browser-like string here works around that; it is offered as a choice rather than done for you.
anchors¶
Type: boolean (default: true)
Added in version 0.34.0.
Whether each URL’s #fragment is verified against the anchors the page
declares — see Anchors for what that means in
detail.
Set it to false to check URLs alone.
Nothing about a fragment is then asked: every request stays HEAD-first with
no body, and no linkcheck.anchor_missing finding can be produced.
allow_private¶
Type: boolean (default: false)
Whether to check URLs pointing at addresses that are not globally routable.
By default such URLs are reported as unchecked and never requested, because
a checker follows URLs written by whoever wrote the document, and those ranges
include internal services and cloud metadata endpoints.
Turn it on for documentation that legitimately links to an intranet.
The refused set is the private ranges, loopback, link-local (which covers
169.254.169.254, the cloud metadata endpoint on every major provider),
CGNAT, the unspecified and broadcast addresses, and the RFC 5737 documentation
ranges — plus, for IPv6, loopback, the unspecified address, and the
IPv4-mapped and IPv4-compatible forms of all of those, so ::ffff:127.0.0.1
and ::127.0.0.1 are refused exactly as 127.0.0.1 is.
The host names localhost and anything under .local are refused by name.
The check is applied to the URL you wrote and again to every redirect it follows, before each hop is requested, so a public URL that redirects into one of those ranges is refused rather than fetched. No DNS lookup is performed, deliberately — it would cost a round trip per URL and rebinding defeats it anyway — so a host name that resolves into a private range is not caught by the address rules.
The cache¶
Results are stored per project, in a single file under the project root — the
directory holding your ubproject.toml, where every other ubCode cache lives,
whatever your source directory is set to:
.ub_cache/linkcheck/v1/results.jsonl
Only successes and redirects are ever stored. A cached failure would hide a link that has since been fixed, which is the one way a cache can make a checker wrong rather than merely slow.
The file lives beside the index cache rather than inside it, deliberately: the index cache is keyed by your whole resolved configuration and is discarded whenever the index layout changes, and neither of those has anything to do with whether a URL is alive. Editing a configuration key therefore does not throw away a slow, politeness- limited network pass.
ubc build linkcheck cache dir # where the file is
ubc build linkcheck cache list # what is stored, in URL order
ubc build linkcheck cache clean # forget everything
ubc build linkcheck cache clean https://example.test/page
A missing, unreadable, corrupt or older-version cache is a clean miss — never an error.
Wiring it into CI¶
Persist .ub_cache between runs and the second and later runs cost no network
requests at all for links that have not expired:
- uses: actions/cache@v4
with:
path: .ub_cache
key: ubc-linkcheck-${{ runner.os }}-${{ github.run_id }}
restore-keys: |
ubc-linkcheck-${{ runner.os }}-
- run: ubc build linkcheck --output-format json > linkcheck.json
The key must be unique per run and the restore-keys prefix is what makes
the previous run’s cache load: actions/cache saves at the end of a job only
when the exact key was not already present, so a constant key restores
the first run’s file forever and never stores a newer one.
With this pair, each run restores the most recent matching cache and saves the
updated one under its own key.
The exit codes follow the house convention:
0Nothing at or above the deny bar.
1The gate tripped — your links have findings. A run refused because the configuration contradicts a flag (
--refreshagainst[linkcheck] offline = true) also exits1: it is a run-time refusal with its own message, not a usage error, because the configuration is not on the command line for the parser to see.2A usage error — the argument parser rejecting the command line itself, for example
--refreshtogether with--offline.
“The tool could not run” — an unreadable configuration, a cache that cannot be written — is an ordinary error with its own message, never conflated with “your links are broken”.
--output-format json writes one versioned document to stdout and demotes
everything else to stderr, so the stream is safe to pipe:
{
"version": 1,
"summary": {
"checked": 12, "occurrences": 30, "from_cache": 9, "requests": 3,
"stale": 0, "suppressed": 0, "unverified": 1, "not_checked": 1,
"counts": {"anchor_missing": 1, "broken": 1, "redirect_permanent": 2,
"working": 9}
},
"links": [
{
"url": "https://example.test/gone",
"status": "broken",
"http_status": 404,
"final_url": null,
"message": "HTTP 404 Not Found",
"from_cache": false,
"code": "linkcheck.broken",
"severity": "error",
"suppressed": false,
"occurrences": [
{"path": "docs/guide.rst", "line": 42, "column": 17,
"kind": "reference", "authored": "https://example.test/gone",
"need": null}
]
},
{
"url": "https://example.test/manual.pdf",
"status": "working",
"http_status": 200,
"final_url": null,
"message": "HTTP 200",
"from_cache": false,
"code": null,
"severity": null,
"suppressed": false,
"occurrences": [
{"path": "docs/guide.rst", "line": 51, "column": 4,
"kind": "reference",
"authored": "https://example.test/manual.pdf#page=3",
"need": null}
],
"fragments": {
"page=3": {
"result": "unverified",
"reason": "Content-Type application/pdf has no HTML anchors"
}
}
}
]
}
fragments is present only for a link some occurrence wrote a #fragment
on, and carries one entry per fragment: present, missing, unverified
(the page was asked and could not answer) or not_checked (the page was never
asked). summary.unverified counts the third and summary.not_checked the
fourth; both keys are always present.
Two counting rules follow from linkcheck.anchor_missing being a finding about
a fragment rather than about a URL:
a missing anchor produces its own
links[]entry, whoseurlcarries the#fragmentand whoseoccurrencesare the subset of the page’s that wrote that fragment. The page keeps its own entry and its own verdict;so
summary.checkedandsummary.occurrencesexclude those entries — they count URLs requested and places in the project — whilesummary.countsincludes them, because that tally is about findings. The two reconcile assum(counts) - counts.anchor_missing == checked.
line and column are 1-based, and column counts UTF-8 bytes into
the line rather than characters.
Either may be null, which means the position is genuinely unknown rather
than that it is the top of the file.
A URL that comes from a need’s own metadata carries a line and a null
column, because the position the need knows is its directive’s, not the URL’s;
a link whose syntax tree node carries no source location has null for both.
The human output does the same thing — it prints file:line:column,
file:line or just file — so a reported position is never a guess.
Fixing redirected links¶
Added in version 0.34.0.
A permanent redirect is a link that works today and is wrong: the target has
moved, the old URL is a courtesy the far end can withdraw at any time, and the
fix is mechanical.
--fix-redirects performs it, in your source:
ubc build linkcheck --fix-redirects --dry-run # print the plan, write nothing
ubc build linkcheck --fix-redirects # apply it
The dry run prints one line per edit, plus a per-file count and every occurrence it will not touch, with the reason:
guide.rst:42:17 https://old.example/page -> https://new.example/page
guide.rst: 1 occurrence(s)
index.rst:8 unfixable: the line is known but not the column, so there is
nothing to search forward from
would fix 1 occurrence(s) in 1 file(s) (1 skipped); nothing was written
What gets written is the redirect target in its normalized spelling — lowercase
scheme and host, a root slash where the server sent none — not the Location
header verbatim.
Only 301 and 308 are rewritten.
A 302/303/307 says the move is temporary, and rewriting your source
on the strength of one turns somebody’s maintenance window into a permanent
edit.
A redirect that an allowed_redirects rule permits is expected rather than a
defect, and is left alone.
Anchors are carried across correctly.
If the redirect points at one — 301 Location: /b#intro — that anchor is what
gets written, because the server is telling you where the content moved to.
If it does not, a #fragment you wrote is kept: a browser applies the
original fragment to the redirect target, so dropping it would change where your
link lands.
What is and is not fixable¶
An occurrence records where the reference starts, not where the URL inside it starts, so the rewrite finds the URL by searching forward from that position — to the end of that line, then, only if that found nothing, across the following line, and no further. It takes the only instance in whatever it searched: if the same URL appears more than once there, it cannot tell which one belongs to the link, so it declines. Everything it cannot locate that way is reported as unfixable rather than approximated. In practice that covers the overwhelming majority of real links; these are the cases it declines, and why:
A URL split across two lines. An embedded URI wrapped mid-URL is joined back together before the checker sees it, so the text it is looking for exists nowhere in your file as a contiguous string.
A Markdown reference-style link. The position is the
[text][ref]use, while the bytes to change are at the[ref]: https://…definition, which can be anywhere in the file. A wider search would rewrite bytes the position does not describe.A link carried by a substitution. It is reported once, at its definition, and the definition is what you edit — but the rewrite declines it, because the definition line records the substitution rather than the URL’s own column.
Anything with no position at all: an
extlinksrole expansion, an RST.. image::URI, asphinx_designdirective argument, or a URL that exists only in a need’s metadata. There is nowhere to point at, so there is nothing to rewrite; the finding still tells you the URL and the file.A link whose text is also its target, written on one line, such as
` `https://example.com/a <https://example.com/a>`__ `or[https://example.com/a](https://example.com/a)— the URL appears twice in the same place the search looks, and the position alone cannot say which one is the link. Change one of them (or both) by hand.If that same link is wrapped, with the text on one line and
<https://example.com/a>`__starting the next, it is not declined: the text is then the only copy on the line the search looks at, so the text is what gets rewritten and the link keeps pointing at the old address. Running the command again fixes the link too, but the file in between displays the new address and links the old one. Check a wrapped link of that shape by hand.A symlinked source file, which is refused rather than replaced.
Before writing, the run verifies that each file is still byte-for-byte the text
that was indexed (a SHA-256 comparison), and that the bytes at the computed
position really are the URL it is about to replace.
A file that changed under the run is skipped whole, with a message asking you to
re-run — and that check is made twice: once when the plan is built, and again
immediately before the file is replaced, because everything else in the project
is planned in between.
So an edit that lands while the run is planning the rest of the project — an
editor autosave, a git checkout — is never overwritten; that file is reported
and left alone.
A symlink is refused rather than followed: replacing it would leave you with a
regular file where your link was, and the real file still unfixed.
Each file is then written through a temporary file in its own directory and
renamed into place, so an interrupted run cannot leave a half-written source
file; the file’s permissions are preserved, and only the URL’s own bytes are
touched — a byte-order mark, CRLF line endings and every character around the
link are left exactly as they were.
A file that cannot be written is reported and does not stop the rest of the run.
Running it twice is safe, and for most links the second run has nothing to do: the bytes at that position are already the new URL, and the run re-indexes anyway. The one shape where a second run still changes something is the wrapped text-equals-target link described above — the first run rewrites the display text, the second the link itself. Two runs converge; neither corrupts anything.
The findings a fixing run prints are the ones it measured, before the edit —
the command does not re-check what it has just rewritten, and says
re-run to verify instead.
The exit code is likewise the one the check earned.
The plan, the summary and any failures are printed whatever --output-format
is in use; under json and sarif they go to stderr, so stdout stays a
pipeable document.
A rewrite can be driven entirely by cached verdicts.
Redirect results are cached, so --fix-redirects --offline will rewrite your
source from a stored answer up to cache_days old without making a single
request.
That is what a network-restricted CI job with a warm cache does, and it is
supported — but so you are never surprised by it, the plan marks such edits
(from cache) and the JSON record sets fixes.from_cache.
Pass --refresh to check before fixing.
Under --output-format json the record gains a fixes object whenever
--fix-redirects was given:
{
"fixes": {
"planned": 1,
"applied": 1,
"dry_run": false,
"from_cache": false,
"failed": [],
"skipped": [
{"path": "index.rst", "line": 8, "column": null,
"authored": "https://old.example/page",
"replacement": "https://new.example/page",
"reason": "no_column",
"message": "unfixable: the line is known but not the column, so there is nothing to search forward from"}
]
}
}
The reason token is stable and safe to branch on; message is the same
thing as a sentence.
Paths in skipped and failed are both relative to the source directory, so
the two lists join.
The tokens are no_position, no_column, unknown_file, unreadable,
hash_unknown, hash_mismatch, not_located, ambiguous,
bytes_differ, overlapping_edit, symlink, unchanged and
allowed_redirect.
Anchors¶
Added in version 0.34.0.
A URL written with a #fragment makes a second claim: not only that the page
is there, but that something on it is called that.
ubc build linkcheck checks both, and
[linkcheck] anchors turns the second one off.
What counts as an anchor¶
An element whose id matches the fragment, or an <a> whose name
matches it.
Matching is exact and case-sensitive — #Install does not reach
id="install" in a browser either — and the percent-decoded spelling is tried
as well, so #a%20b finds id="a b" and a literal %20 in an id
still matches too.
#top is always present, on every page, whether or not the page declares an
id="top": that is what a browser does with it.
name is accepted on <a> only, which is what the HTML standard says
and a deliberate difference from Sphinx and from lychee, both of which accept it
on any element.
On this project’s own rendered documentation their rule finds 503 extra
“anchors” — every one of them on an <input> or a <meta>, none on an
<a> — which would grade a link written #viewport as working.
The four states¶
- present
A candidate spelling matched something the page declares.
- missing
The whole page was read and nothing matched. This is the only state that produces a
linkcheck.anchor_missingfinding.- unverified
The page was asked and could not answer the question. Reported as a count, never as a finding: the summary line reads
N unverified anchor(s)and--output-format jsoncarriessummary.unverifiedplus a per-linkfragmentsobject naming each fragment, its state and — for this state — the reason.
A fragment is unverified when:
the page did not answer
2xx. The URL has its own verdict; its anchors do not add a second one;the response is not
text/htmlorapplication/xhtml+xml, or carries noContent-Typeat all. A PDF answering200to#page=3is a working link to a page-3 view, not a missing anchor;the response arrived in a content encoding that was never requested, so the bytes are not a document at all;
the body was larger than the 8 MiB limit and the anchor was not in the part that was read. A truncated body can prove an anchor is there, never that it is absent;
the page’s bytes reached the checker in an encoding it could not decode, so the comparison would be meaningless. A page that declares its encoding is converted for us and is checked normally — this is about the pages that do not: no
charsetat all, one nothing recognises, or a media type that is nottext/*. A body that is not valid UTF-8 declines the non-ASCII fragments only, because the tags are still ASCII so the page is read and an ASCII fragment either matches or genuinely is not there. A body containing a NUL byte — UTF-16 or UTF-32 that arrived unconverted — declines every fragment, because no tag can be read out of it at all. A page that merely carries a stray NUL (binary inside a<script>, a padded response) is treated the same way: every anchor on it is unverified rather than a possible false finding;a quirk recognised the fragment as client-side state.
A fragment on a URL that was never requested — one an
ignore pattern covers, one using a scheme this
checker does not verify, one whose host never answered, or one served from a
stored result under offline that had never been
asked that anchor — is a fourth state, not_checked.
It appears in the per-link fragments object with its reason, is counted
separately as summary.not_checked, and the summary line reads
N anchor(s) not checked beside the unverified clause — both omitted at
zero. Without that clause an --offline run over a project whose links carry
fragments printed no anchor-level signal at all, which reads exactly like a run
that checked them.
It is deliberately not counted as unverified: those URLs already have their
own verdict in the report, and counting their anchors again in a second unit
would both restate the same fact and make summary.unverified impossible to
gate on — one ignore rule over a host you link with a fragment would pin it
above zero for ever.
The rule the command holds itself to is that anchor_missing is reported
only when the whole body was read and nothing matched.
Everything short of that, on a page that was read, is a counted unverified,
because a number you can see is a promise to come back to it and a silent skip
is not.
One request per page¶
A page linked with at least one fragment is fetched with a single GET that
reads its body; the HEAD is skipped outright rather than sent and thrown
away.
Every fragment of that page is answered from that one response, however many
of its anchors your project links to.
A page linked without any fragment is unaffected and still costs only headers.
One consequence is worth knowing before you turn this on, because it can change a
page’s verdict rather than a fragment’s: a fragment-bearing URL goes straight
to GET, with no fallback back to HEAD.
A host that answers HEAD 200 but 405, 403 or 429 to a GET —
bot protection that only fires on GET, or a server for which GET is the
expensive method — will therefore be reported as broken, blocked or rate-limited
where it previously passed.
[linkcheck] anchors = false is the escape, and
[linkcheck] ignore is the per-host one.
Fragments are checked against the final page of a redirect chain, and the
gate is the HTTP status rather than the verdict: a URL reported as
redirect_permanent still has its anchors checked, because a redirect is a
finding about the URL’s spelling and not about its reachability.
If a redirect’s Location carries a fragment of its own — 301 Location:
/b#intro — that one wins, because the server is saying where inside the new
page the content went; otherwise the fragment you wrote is re-applied to the
target, which is what a browser does.
Results are stored in the result cache beside the page’s own verdict and expire with it. A cached page that is asked about a fragment it has never been asked about is re-fetched once, and one request answers every fragment.
A missing anchor is stored too, which is the one place the
“failures are not cached” rule does not reach: the rule
is about the page, whose own result is unaffected, and a fragment answer is
exactly as time-dependent as a status.
So if the target page’s owner adds the anchor, the finding can persist for up
to cache_days — five days by default.
--refresh, or ubc build linkcheck cache clean <url>, re-asks sooner.
Fixing your own #fragment needs neither: it changes the fragment the run
asks about, which the stored entry cannot answer, so the page is re-fetched on the
very next run.
Quirks¶
Some fragments are not claims about the served HTML at all — they are state a
browser interprets and no page declares.
Checking those would report a confident missing anchor on a link that works
perfectly, so they are recognised by name and reported unverified.
Each entry names itself in the reason, so a decline you disagree with can be
found here from the words in your own report.
strip line-number fragmentsThe line and line-range fragments the forges emit, each in the spelling that forge actually uses:
GitHub, GitLab, Gitea and Forgejo —
#L12,#L12-L20, the#L5-15shorthand, and GitHub’s column form#L12C3/#L12C1-L18/#L12C3-L14C9;Bitbucket Cloud —
#lines-12and#lines-12:20;Sourcegraph —
#L12:5-14:9;cgit —
#n12.
Recognised on
github.com,gitlab.comandbitbucket.org, and on any URL whose path carries/blob/,/-/blob/,/src/or/tree/— which covers self-hosted Gitea, Forgejo, GitLab, Sourcegraph and cgit, whose host names cannot be known in advance. Both halves are required, so an ordinary#L12heading on a documentation site is still checked./tree/is the exception worth knowing: it is a common path segment on ordinary sites too, so a realid="L9"under a/tree/path is declined. The cost is a fragment reported as unverified rather than checked, never a link reported as broken.github blob-view anchorsAny
github.com/<owner>/<repo>/blob/…URL with a fragment, whatever the file type. GitHub’s file view renders content anchors in the browser, so the served page does not declare them — for markdown, and equally for the reStructuredText, AsciiDoc, Org, Textile and other markups it renders. The document is still checked. This is a known limitation rather than a permanent answer — resolving such a fragment properly means reimplementing GitHub’s own heading-slug algorithm, which is unversioned and can change without notice.github user-content candidateNot a decline. For any
github.comhost,user-content-<fragment>is added as an extra candidate spelling, because GitHub prefixes author-supplied ids and re-attaches the un-prefixed behaviour in JavaScript. It can only turn a false failure into a pass.hashbang fragmentsA fragment beginning with
!— the AJAX-crawling convention, which is pure client-side state.
Two quirks other tools carry are deliberately not here.
A crates.io-specific Accept header is unnecessary: it works around a
default Accept: */* that this command does not send, and a per-host header
is what [linkcheck.request_headers] is
for. Rewriting a YouTube URL to its thumbnail image checks a different
resource, which would then be the URL offered to
–fix-redirects to write into your source.
Escalating and silencing¶
A missing anchor is a warning, so it does not fail the command by default. To make it fail:
[linkcheck]
deny = "warning"
or pass --deny warning for one run.
To silence the class entirely, or for particular files, use the ordinary
lint filters — [lint] ignore takes
linkcheck.anchor_missing like any other code.
SARIF output¶
Added in version 0.34.0.
--output-format sarif writes a SARIF 2.1.0 document to stdout — one result
per occurrence, with the rule metadata, a stable fingerprint and, for every
permanent redirect, a machine-applicable fix.
Uploaded to GitHub code scanning, each finding appears inline on the pull
request’s diff:
- run: ubc build linkcheck --output-format sarif > linkcheck.sarif
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: linkcheck.sarif
if: always() on the upload step is what makes this work, and it is the only
thing needed.
A step without it does not run once an earlier step has failed, so a job with a
dead link would upload nothing — exactly when the annotations are worth having.
With it, the check step fails the job and the annotations are still uploaded,
from a single run: a second run would be a second traffic budget spent on
third-party servers, even with a warm cache.
Do not add continue-on-error: true to the check step to achieve this.
It does the opposite of what is wanted here: its purpose is to stop a failing
step from failing the job, so the annotations would upload and CI would stay
green on a dead link.
Three details are worth knowing:
Columns are UTF-16 code units, which is what the format requires — not the UTF-8 byte columns the JSON record carries. The conversion needs the source line, so a file that cannot be read yields a result with a line and no column rather than a wrong one.
Paths are relative to your repository root when one can be found (a
.gitdirectory at or above the source directory), because that is what code scanning resolves them against. With no repository, paths stay relative to the source directory and the document declares what they are relative to. A source that lives outside the source directory — through [[source.mounts]] — is emitted as an absolutefile://URI with no base, which is honest but is not something code scanning can annotate.An unknown position stays unknown. A finding with a line and no column gets a region with only a line; one with neither gets a location with no region; one with no file at all — a URL from an external need source — is attached to the need by name. No position is ever invented.
Two more things to know if you combine it with --fix-redirects.
The machine-applicable fixes are computed from the file as it is, without the
--fix-redirects staleness check, so against a stale index the document can
offer a fix the fixer itself would refuse.
And when the same run also applied fixes, the document describes the state
before them — which is what its findings describe — and offers no fix for an edit
it has already made.
A file whose write failed keeps its fixes, and so does every result of a
--dry-run.
Findings silenced by [lint] are omitted from the document entirely.
Per-host settings¶
Added in version 0.34.0.
Two tables adjust how a particular host is treated.
[linkcheck.hosts."slow.example.com"]
max_concurrent = 1 # default: 2
min_interval_ms = 1000 # default: 250
[linkcheck.hosts] is keyed by host name — no scheme, no port, no path — and
matched case-insensitively (ASCII).
An internationalised host must be written in its punycode form
(xn--bcher-kva.example), which is the form a URL is requested under; a
Unicode spelling never matches.
Use it for a host that is slow, that rate-limits, or that your project links to
hundreds of times.
max_concurrent = 0 is treated as 1, since zero would stop the host being
checked rather than throttling it; min_interval_ms = 0 is allowed and means
no gap at all, which is only sensible for a host you run yourself.
[linkcheck.request_headers."https://api.example.com/"]
Accept = "application/json"
Authorization = "Bearer ${EXAMPLE_TOKEN}"
[linkcheck.request_headers] is keyed by URL prefix, and the key must be an
absolute http/https URL.
Its host is what the headers are scoped to: a URL gets them only when its host is
exactly that host and the URL starts with the prefix.
A bare host name, a key with userinfo before the host (https://a@b/), a key
carrying a #fragment and an empty key are each refused when the run starts,
with a message naming the key.
The longest matching prefix wins, and only its headers are sent — entries do
not accumulate, so a specific prefix can replace a general one’s
Authorization rather than adding to it.
A header value of the form ${VARIABLE} is replaced with that environment
variable when the run starts, which is how a token reaches a request without
being written into a file you commit.
Header names are never expanded, and neither is the prefix.
A variable that is not set, a ${ that is never closed, and an empty ${}
each refuse the run and name the key: the alternatives are sending the
literal text to a third party, or silently omitting the header and reporting
everything behind that authentication as blocked for a reason you cannot see.
A header name or value HTTP does not admit — a space in a name, a newline in a
value — is refused there too, rather than failing every request under the prefix
with an opaque transport error.
Headers are not forwarded across a redirect to a different host.
Both the host and the prefix are matched against each URL as it is requested, so
a credential you wrote for one host is never sent to another — including when
the first host redirects there, and including when the other host’s name merely
begins with yours (api.example.com.attacker.test), which a prefix match
alone would not have caught.
What is checked, and what is not¶
Checked:
hyperlink references and targets, in reStructuredText and Markdown alike — named, anonymous, indirect and embedded-URI forms all converge on one thing by the time the checker sees them;
remote image sources and the
:target:an image links to;remote
.. video::sources and posters;external
:download:targets;toctreeentries that name an external URL (Report an issue <https://example.test/issues>);a need’s
external_url, and URLs rendered by [needs.string_links] rules.
Two counting rules are worth stating, because they are the two places the number of occurrences differs from what a rendered page would suggest:
``.. only::`` blocks are checked, whatever their tag expression says. The checker reads your source rather than one build’s output, and a link that only a pre-release build renders is still a link that will rot. So a URL written inside an
.. only::block is checked even for a build that would drop the block.A link carried by a substitution counts once, at the substitution’s definition — which is the one place you would edit it — however many times the substitution is used.
Not checked in this release:
Absolute URLs that came from an intersphinx inventory. Those are checked by whichever project published the inventory, not by the one consuming it.
Links inside ``raw`` blocks, which are opaque text this project does not parse.
Local file links, which the reference resolver already owns — this command is about external URLs.
Deliberately not built:
Anything that circumvents a bot-block. A challenge page is reported as
blocked; useignorefor a host that will never answer a checker, orallow_privatefor one only your network can reach.Crawling. Authored URLs are verified; nothing is discovered.
``robots.txt`` consultation. A specific URL a human already published a link to is verified once, which is not crawling. The obligations that do bind — an honest
User-Agent,429compliance and per-host caps — are all kept.