Skip to content

Commit f458e50

Browse files
committed
Check the website field against the declared documentation URL
The check only asserted the field was non-empty, so a documentation site that moves to a new domain leaves the repository sidebar pointing at the origin it left, with nothing to report it: `meta-package-manager` has been in that state since its docs moved to `mpm.run`, every published page naming the new origin as canonical while the sidebar still sends visitors to `github.io`. A trailing slash and the case of the scheme and host are ignored, since GitHub stores the website field with the slash a browser appends and `[project.urls]` is usually written without one.
1 parent 41f11ad commit f458e50

5 files changed

Lines changed: 220 additions & 8 deletions

File tree

changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
> [!WARNING]
66
> This version is **not released yet** and is under active development.
77
8+
- `lint-repo` now warns when a Sphinx project's GitHub website field differs from the documentation URL declared in `[project.urls]`.
9+
810
## [`7.11.0` (2026-08-13)](https://github.com/kdeldycke/repomatic/compare/v7.10.0...v7.11.0)
911

1012
- **Breaking:** `labels.content-rules` and `labels.file-rules` are now tables mapping each label to its patterns, like `"📚 docs" = ["docs/**"]`. The array-of-tables form and its `actions/labeler` v5 matcher schema are gone; an un-migrated config is ignored with a warning.

docs/workflows.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ None of these jobs read a label config committed to the repository. `labels.toml
399399
#### 🏠 Lint repository metadata (`lint-repo`)
400400

401401
- Validates repository metadata (package name, Sphinx docs, project description) and Dependabot configuration using [`repomatic lint-repo`](https://github.com/kdeldycke/repomatic/blob/main/repomatic/cli.py). Reads `pyproject.toml` directly. When `REPOMATIC_PAT` is configured, also validates PAT capabilities (contents, issues, pull requests, Dependabot alerts, workflows permissions). Warns when the fork PR workflow approval policy is weaker than `first_time_contributors`. Warns about missing `VIRUSTOTAL_API_KEY` when Nuitka binary compilation is active. Warns about missing `REPOMATIC_NOTIFICATIONS_PAT` when the unsubscribe workflow is enabled.
402+
- Warns when a Sphinx project's GitHub website field does not name the documentation URL it declares in `[project.urls]` (`Documentation`, then `Docs`). A trailing slash and the case of the scheme and host are ignored, since GitHub stores the website field with the slash a browser appends. Moving a documentation site to a new domain is what this catches: Sphinx renders `<link rel="canonical">` from `html_baseurl`, so every published page names the new origin while the repository sidebar keeps sending visitors to the old one. A project declaring no documentation URL keeps the presence-only check
402403
- Warns when a release download URL in `docs/install.md` names a file its release does not carry. The release freeze pins those URLs before the binaries exist, so a failed build lane leaves the guide advertising 404s until the next release moves past it: this is the check that surfaces the gap instead of leaving it for a user to hit. Versionless `releases/latest/download` URLs are checked against the latest published release too, and rot longer: nothing rewrites them at release time, so a renamed asset leaves one pointing at a 404 indefinitely
403404
- **Requires**:
404405
- Python package (with a `pyproject.toml` file)

repomatic/cli.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@
188188
)
189189
from .lint_repo import (
190190
KNOWN_RUNNERS,
191+
documentation_url,
191192
run_repo_lint,
192193
)
193194
from .mailmap import Mailmap, remove_header
@@ -2075,7 +2076,8 @@ def lint_repo(
20752076
\b
20762077
Checks:
20772078
- Package name vs repository name (warning).
2078-
- Website field set for Sphinx projects (warning).
2079+
- Website field set for Sphinx projects, and matching the documentation
2080+
URL declared in [project.urls] (warning).
20792081
- Repository description matches project description (error).
20802082
- GitHub topics subset of pyproject.toml keywords (warning).
20812083
- Funding file present when owner has GitHub Sponsors (warning).
@@ -2114,12 +2116,15 @@ def lint_repo(
21142116
# Extract repo name from owner/repo format.
21152117
repo_name = repo.split("/")[-1] if "/" in repo else repo
21162118

2117-
# Derive package_name, is_sphinx, project_description, keywords from pyproject.toml.
2119+
# Derive package_name, is_sphinx, project_description, docs_url and keywords
2120+
# from pyproject.toml.
21182121
metadata = Metadata()
21192122
package_name = get_project_name()
21202123
is_sphinx = metadata.is_sphinx
21212124
project_description = metadata.project_description
2122-
keywords = metadata.pyproject_toml.get("project", {}).get("keywords")
2125+
project_table = metadata.pyproject_toml.get("project", {})
2126+
docs_url = documentation_url(project_table.get("urls"))
2127+
keywords = project_table.get("keywords")
21232128

21242129
config = get_tool_config(ctx)
21252130
nuitka_active = config.nuitka_enabled and bool(metadata.script_entries)
@@ -2130,6 +2135,7 @@ def lint_repo(
21302135
is_package=metadata.is_python_package,
21312136
is_sphinx=is_sphinx,
21322137
project_description=project_description,
2138+
docs_url=docs_url,
21332139
keywords=keywords,
21342140
repo=repo if repo else None,
21352141
has_pat=has_pat,

repomatic/lint_repo.py

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from functools import cached_property
3333
from pathlib import Path
3434
from typing import NamedTuple
35+
from urllib.parse import urlsplit
3536

3637
import yaml
3738
from click_extra import echo
@@ -59,7 +60,17 @@
5960

6061
TYPE_CHECKING = False
6162
if TYPE_CHECKING:
62-
from collections.abc import Callable, Iterable, Iterator
63+
from collections.abc import Callable, Iterable, Iterator, Mapping
64+
65+
DOCS_URL_KEYS = ("documentation", "docs")
66+
"""Keys in `[project.urls]` naming the published documentation site.
67+
68+
Checked in priority order, and looked up in a lowercased index of the
69+
project's own keys: PEP 621 leaves the spelling to the project, so
70+
`Documentation`, `documentation` and `Docs` all occur in the wild. Mirrors the
71+
same convention {data}`repomatic.pypi._SOURCE_URL_KEYS` applies to the PyPI
72+
copy of the same mapping.
73+
"""
6374

6475
WORKFLOW_DIR = Path(WORKFLOW_TARGET_ROOT)
6576
"""Directory every workflow check walks.
@@ -206,14 +217,76 @@ def check_package_name_vs_repo(package_name: str | None, repo_name: str) -> Chec
206217
return CheckResult(True, f"Package name '{package_name}' matches repository name.")
207218

208219

220+
def _url_key(url: str) -> tuple[str, str, str, str, str]:
221+
"""Reduce a URL to what two spellings of the same address share.
222+
223+
Lowercases the scheme and host, which are case-insensitive per
224+
[RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#section-3.1), and
225+
drops a trailing slash. GitHub stores the website field with the trailing
226+
slash a browser appends, while `[project.urls]` is usually written without
227+
one, so comparing raw strings would report a correctly configured
228+
repository as a mismatch.
229+
230+
Every other difference survives. A path's case is significant, and `http`
231+
and `https` really are two origins: those are the splits a comparison
232+
exists to surface, not noise to smooth over.
233+
"""
234+
parts = urlsplit(url.strip())
235+
return (
236+
parts.scheme.lower(),
237+
parts.netloc.lower(),
238+
parts.path.rstrip("/"),
239+
parts.query,
240+
parts.fragment,
241+
)
242+
243+
244+
def documentation_url(project_urls: Mapping[str, str] | None) -> str | None:
245+
"""The documentation site a project declares in `[project.urls]`.
246+
247+
:param project_urls: The `[project.urls]` mapping, keys untouched.
248+
:return: The first URL found per {data}`DOCS_URL_KEYS`, or `None` when the
249+
project declares none.
250+
"""
251+
if not project_urls:
252+
return None
253+
by_key = {key.lower(): str(value).strip() for key, value in project_urls.items()}
254+
for key in DOCS_URL_KEYS:
255+
if candidate := by_key.get(key):
256+
return candidate
257+
return None
258+
259+
209260
def check_website_for_sphinx(
210-
repo: str, is_sphinx: bool, homepage_url: str | None = None
261+
repo: str,
262+
is_sphinx: bool,
263+
homepage_url: str | None = None,
264+
docs_url: str | None = None,
211265
) -> CheckResult:
212-
"""Check that Sphinx projects have a website set.
266+
"""Check that a Sphinx project's website field names its documentation.
267+
268+
GitHub renders the website field in the repository sidebar, and for a
269+
project publishing Sphinx documentation that is where a visitor expects to
270+
land. So the check has two halves: the field is set at all, and it names
271+
the site the project itself declares under {data}`DOCS_URL_KEYS`.
272+
273+
The second half is what a documentation move leaves behind. Sphinx emits
274+
`<link rel="canonical">` from `html_baseurl`, and a `conf.py` commonly
275+
derives that from the same `[project.urls]` entry, so a project that moves
276+
to a new domain has every published page naming the new origin as canonical
277+
while the sidebar keeps sending visitors to the one it replaced. Nothing
278+
but a reader noticing connects the two.
279+
280+
```{note}
281+
A project declaring no documentation URL gets the presence half only. The
282+
comparison needs the project to have named an expected answer, and nothing
283+
here invents one from the repository slug.
284+
```
213285
214286
:param repo: Repository in 'owner/repo' format.
215287
:param is_sphinx: Whether the project uses Sphinx documentation.
216288
:param homepage_url: The homepage URL from API (to avoid duplicate calls).
289+
:param docs_url: Documentation URL declared in `[project.urls]`.
217290
:return: A `CheckResult`.
218291
"""
219292
if not is_sphinx:
@@ -226,7 +299,20 @@ def check_website_for_sphinx(
226299
if not homepage_url:
227300
msg = "Sphinx documentation detected but repository website field is not set."
228301
return CheckResult(False, msg)
229-
return CheckResult(True, f"Website field is set: {homepage_url}")
302+
303+
if not docs_url:
304+
return CheckResult(True, f"Website field is set: {homepage_url}")
305+
306+
if _url_key(homepage_url) != _url_key(docs_url):
307+
msg = (
308+
f"Repository website field '{homepage_url}' differs from the"
309+
f" documentation URL '{docs_url}' declared in [project.urls]."
310+
)
311+
return CheckResult(False, msg)
312+
313+
return CheckResult(
314+
True, f"Website field matches the documentation URL: {homepage_url}"
315+
)
230316

231317

232318
def check_description_matches(
@@ -1840,6 +1926,9 @@ class LintContext:
18401926
project_description: str | None = None
18411927
"""Description from `pyproject.toml`."""
18421928

1929+
docs_url: str | None = None
1930+
"""Documentation site declared in `[project.urls]`, per {data}`DOCS_URL_KEYS`."""
1931+
18431932
keywords: list[str] | None = None
18441933
"""Keywords list from `pyproject.toml`."""
18451934

@@ -1963,7 +2052,10 @@ def _pat_permissions(ctx: LintContext) -> Iterator[CheckResult]:
19632052
RepoCheck(
19642053
"website-for-sphinx",
19652054
lambda ctx: check_website_for_sphinx(
1966-
ctx.repo or "", ctx.is_sphinx, ctx.repo_metadata.get("homepageUrl")
2055+
ctx.repo or "",
2056+
ctx.is_sphinx,
2057+
ctx.repo_metadata.get("homepageUrl"),
2058+
ctx.docs_url,
19672059
),
19682060
applies=lambda ctx: ctx.is_sphinx,
19692061
),
@@ -2093,6 +2185,7 @@ def run_repo_lint(
20932185
is_package: bool = False,
20942186
is_sphinx: bool = False,
20952187
project_description: str | None = None,
2188+
docs_url: str | None = None,
20962189
keywords: list[str] | None = None,
20972190
repo: str | None = None,
20982191
has_pat: bool = False,
@@ -2113,6 +2206,7 @@ def run_repo_lint(
21132206
:param is_package: Whether the project builds a distributable package.
21142207
:param is_sphinx: Whether the project uses Sphinx documentation.
21152208
:param project_description: Description from pyproject.toml.
2209+
:param docs_url: Documentation URL declared in `[project.urls]`.
21162210
:param keywords: Keywords list from pyproject.toml.
21172211
:param repo: Repository in 'owner/repo' format.
21182212
:param has_pat: Whether `GH_TOKEN` contains `REPOMATIC_PAT`.
@@ -2130,6 +2224,7 @@ def run_repo_lint(
21302224
is_package=is_package,
21312225
is_sphinx=is_sphinx,
21322226
project_description=project_description,
2227+
docs_url=docs_url,
21332228
keywords=keywords,
21342229
repo=repo,
21352230
has_pat=has_pat,

tests/test_lint_repo.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
check_topics_subset_of_keywords,
4747
check_website_for_sphinx,
4848
check_workflow_permissions,
49+
documentation_url,
4950
get_repo_metadata,
5051
run_repo_lint,
5152
)
@@ -145,6 +146,95 @@ def test_sphinx_fetches_metadata():
145146
mock_get.assert_called_once_with("owner/repo")
146147

147148

149+
@pytest.mark.parametrize(
150+
("project_urls", "expected"),
151+
(
152+
(None, None),
153+
({}, None),
154+
({"Homepage": "https://papaya.example"}, None),
155+
(
156+
{"Documentation": "https://docs.papaya.example"},
157+
"https://docs.papaya.example",
158+
),
159+
# PEP 621 fixes neither the case nor the wording of the key.
160+
(
161+
{"documentation": "https://docs.papaya.example"},
162+
"https://docs.papaya.example",
163+
),
164+
({"DOCS": "https://docs.papaya.example"}, "https://docs.papaya.example"),
165+
# `Documentation` outranks `Docs` when a project declares both.
166+
(
167+
{"Docs": "https://kiwi.example", "Documentation": "https://papaya.example"},
168+
"https://papaya.example",
169+
),
170+
# An empty value is not a declaration.
171+
(
172+
{"Documentation": " ", "Docs": "https://papaya.example"},
173+
"https://papaya.example",
174+
),
175+
),
176+
)
177+
def test_documentation_url(project_urls, expected):
178+
"""Resolve the documentation site a project declares in `[project.urls]`."""
179+
assert documentation_url(project_urls) == expected
180+
181+
182+
@pytest.mark.parametrize(
183+
("homepage_url", "docs_url"),
184+
(
185+
# GitHub stores the trailing slash a browser appends, while
186+
# `[project.urls]` is usually written without one.
187+
("https://papaya.example/", "https://papaya.example"),
188+
("https://papaya.example", "https://papaya.example/"),
189+
("https://kiwi.github.io/papaya/", "https://kiwi.github.io/papaya"),
190+
# Scheme and host are case-insensitive per RFC 3986.
191+
("HTTPS://Papaya.Example", "https://papaya.example"),
192+
),
193+
)
194+
def test_sphinx_website_matches_docs_url(homepage_url, docs_url):
195+
"""Pass when the website field and the declared docs URL name one address."""
196+
result = check_website_for_sphinx(
197+
"owner/repo", is_sphinx=True, homepage_url=homepage_url, docs_url=docs_url
198+
)
199+
assert result.passed is True
200+
assert "matches" in result.message
201+
202+
203+
@pytest.mark.parametrize(
204+
("homepage_url", "docs_url"),
205+
(
206+
# The move this check exists to catch: the documentation gained a
207+
# domain and the sidebar kept pointing at the origin it left.
208+
("https://kiwi.github.io/papaya", "https://papaya.example"),
209+
# A path's case is significant, unlike a host's.
210+
("https://papaya.example/Docs", "https://papaya.example/docs"),
211+
# Two origins, not one.
212+
("http://papaya.example", "https://papaya.example"),
213+
("https://papaya.example/docs", "https://papaya.example/manual"),
214+
),
215+
)
216+
def test_sphinx_website_differs_from_docs_url(homepage_url, docs_url):
217+
"""Fail when the website field names something other than the docs URL."""
218+
result = check_website_for_sphinx(
219+
"owner/repo", is_sphinx=True, homepage_url=homepage_url, docs_url=docs_url
220+
)
221+
assert result.passed is False
222+
assert homepage_url in result.message
223+
assert docs_url in result.message
224+
225+
226+
def test_sphinx_website_without_declared_docs_url():
227+
"""Keep the presence-only check when the project declares no docs URL."""
228+
result = check_website_for_sphinx(
229+
"owner/repo",
230+
is_sphinx=True,
231+
homepage_url="https://kiwi.github.io/papaya",
232+
docs_url=None,
233+
)
234+
assert result.passed is True
235+
assert "is set" in result.message
236+
237+
148238
def test_descriptions_match():
149239
"""No error when descriptions match."""
150240
result = check_description_matches(
@@ -244,6 +334,24 @@ def test_website_warning(capsys):
244334
assert "::warning::" in captured.out
245335

246336

337+
def test_website_docs_url_mismatch_warning(capsys):
338+
"""A website field pointing away from the docs URL warns without failing."""
339+
with patch("repomatic.lint_repo.get_repo_metadata") as mock_get:
340+
mock_get.return_value = {
341+
"homepageUrl": "https://kiwi.github.io/papaya",
342+
"description": None,
343+
}
344+
exit_code = run_repo_lint(
345+
is_sphinx=True,
346+
docs_url="https://papaya.example",
347+
repo="owner/repo",
348+
)
349+
assert exit_code == 0
350+
captured = capsys.readouterr()
351+
assert "::warning::" in captured.out
352+
assert "https://papaya.example" in captured.out
353+
354+
247355
@pytest.mark.parametrize(
248356
("unsubscribe_active", "has_notifications_pat", "expected"),
249357
(

0 commit comments

Comments
 (0)