From ea4e51224f3a7af0d8e6dc5a6263574dc4023994 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Fri, 16 Oct 2026 17:00:26 -0700 Subject: [PATCH 1/6] ci: check default-branch repository links against the proposed checkout Signed-off-by: Ralf W. Grosse-Kunstleve --- .github/actions/lychee-links/action.yml | 2 +- .github/workflows/build-docs.yml | 13 ++ .github/workflows/lychee.yml | 12 + .github/workflows/pre-commit.yml | 12 + ci/README.md | 64 ++++- ci/tools/checkout_link_defaults.py | 190 +++++++++++++++ ci/tools/run_lychee.py | 6 +- ci/tools/tests/test_checkout_link_defaults.py | 219 ++++++++++++++++++ ci/tools/tests/test_run_lychee.py | 3 + 9 files changed, 516 insertions(+), 5 deletions(-) create mode 100644 ci/tools/checkout_link_defaults.py create mode 100644 ci/tools/tests/test_checkout_link_defaults.py diff --git a/.github/actions/lychee-links/action.yml b/.github/actions/lychee-links/action.yml index 670c7ad074d..85273c16543 100644 --- a/.github/actions/lychee-links/action.yml +++ b/.github/actions/lychee-links/action.yml @@ -69,7 +69,7 @@ runs: KIND: ${{ inputs.kind }} # Anything that can change a check result or the cache's meaning. The # hook configuration carries the native wheel pin and every hook option. - POLICY_HASH: ${{ hashFiles('.pre-commit-config.yaml', 'lychee.toml', 'ci/tools/run_lychee.py', 'ci/tools/run_lychee_precommit.py', 'ci/tools/retry_lychee.py') }} + POLICY_HASH: ${{ hashFiles('.pre-commit-config.yaml', 'lychee.toml', 'ci/tools/run_lychee.py', 'ci/tools/run_lychee_precommit.py', 'ci/tools/retry_lychee.py', 'ci/tools/checkout_link_defaults.py') }} run: | # Authored and rendered results stay apart: only rendered checks validate # fragments, and Lychee's cache does not record which validation ran. diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 6e52bc71b15..e7716137e03 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -295,6 +295,19 @@ jobs: with: python-version: "3.14" + - name: Discover repository link targets + if: ${{ !inputs.is-release && startsWith(github.ref_name, 'pull-request/') }} + # BEGIN SYNCED LYCHEE REPOSITORY METADATA + env: + LYCHEE_REPOSITORY: ${{ github.repository }} + LYCHEE_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: >- + python ci/tools/checkout_link_defaults.py + --repository "$LYCHEE_REPOSITORY" + --default-branch "$LYCHEE_DEFAULT_BRANCH" + --github-env "$GITHUB_ENV" + # END SYNCED LYCHEE REPOSITORY METADATA + - name: Check rendered documentation links through pre-commit if: ${{ !inputs.is-release && startsWith(github.ref_name, 'pull-request/') }} timeout-minutes: 60 diff --git a/.github/workflows/lychee.yml b/.github/workflows/lychee.yml index 6a3f519ce9b..2356e74daf7 100644 --- a/.github/workflows/lychee.yml +++ b/.github/workflows/lychee.yml @@ -75,6 +75,18 @@ jobs: - name: Set pre-commit cache location run: echo "PRE_COMMIT_HOME=${RUNNER_TEMP}/pre-commit" >> "${GITHUB_ENV}" + - name: Discover repository link targets + # BEGIN SYNCED LYCHEE REPOSITORY METADATA + env: + LYCHEE_REPOSITORY: ${{ github.repository }} + LYCHEE_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: >- + python ci/tools/checkout_link_defaults.py + --repository "$LYCHEE_REPOSITORY" + --default-branch "$LYCHEE_DEFAULT_BRANCH" + --github-env "$GITHUB_ENV" + # END SYNCED LYCHEE REPOSITORY METADATA + - name: Restore pre-commit environments uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index a0cf2aa0b49..b95a9458ca9 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -70,6 +70,18 @@ jobs: - name: Set pre-commit cache location run: echo "PRE_COMMIT_HOME=${RUNNER_TEMP}/pre-commit" >> "${GITHUB_ENV}" + - name: Discover repository link targets + # BEGIN SYNCED LYCHEE REPOSITORY METADATA + env: + LYCHEE_REPOSITORY: ${{ github.repository }} + LYCHEE_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: >- + python ci/tools/checkout_link_defaults.py + --repository "$LYCHEE_REPOSITORY" + --default-branch "$LYCHEE_DEFAULT_BRANCH" + --github-env "$GITHUB_ENV" + # END SYNCED LYCHEE REPOSITORY METADATA + - name: Restore pre-commit cache id: precommit-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/ci/README.md b/ci/README.md index dcd5047b05a..170ebfa0041 100644 --- a/ci/README.md +++ b/ci/README.md @@ -30,7 +30,9 @@ Two pre-commit hooks check links with one pinned native wheel (`lychee-bin`, see `.pre-commit-config.yaml`) through the wrapper `ci/tools/run_lychee.py`: `lychee` checks authored Markdown and reST (fragments unchecked), and the manual-stage `lychee-rendered` checks the generated HTML in `artifacts/docs`, -including fragments. Developers run the same hooks locally. +including fragments. Developers run the same hooks locally. Links to supported +repositories' default branches are checked against the proposed checkout, as +described under [Repository Links](#repository-links). CI runs a hook through `ci/tools/run_lychee_precommit.py `, wrapped with the shared cache and report handling in @@ -95,13 +97,69 @@ can stay unnoticed for up to 72 hours. The action keys each baseline by checker kind and a hash of `.pre-commit-config.yaml` (hook options and wheel pin), `lychee.toml`, and -`ci/tools/run_lychee.py`, `run_lychee_precommit.py`, and `retry_lychee.py`, so -editing any of them starts a cold cache. +`ci/tools/run_lychee.py`, `run_lychee_precommit.py`, `retry_lychee.py`, and +`checkout_link_defaults.py`, so editing any of them starts a cold cache. Authored and rendered caches stay apart because only rendered checks validate fragments and Lychee's cache does not record which validation ran. URL results are shared across operating systems; pre-commit's installed environments keep their OS- and Python-specific caches. +### Repository Links + +Documentation can link to a path added or moved in the same PR using its +intended default-branch URL. For example, a URL ending in +`tree//new-directory` is validated before that directory +exists on the remote default branch. Lychee remaps the +validation target; source files and published documentation keep their URLs. + +`ci/tools/checkout_link_defaults.py` discovers each source repository's default +branch, rather than assuming it is named `main`. It supports the canonical +public repository and the repository being checked. A synchronized repository +with a different default branch, such as `ctk-next`, can retain public source +links and also use its own repository links. Each source URL is matched against +the default branch of the repository named in that URL. + +- Authored Markdown and reST map to file URIs in the working tree, including + uncommitted and unpushed paths. Missing files and directories fail even if a + prior check succeeded. Authored checks continue to omit fragment validation. +- Rendered HTML maps to GitHub's file or directory view at the actual + checked-out commit in the current repository, preserving fragment checks. +- Only complete URL prefixes naming a supported repository and its default + branch are remapped. Tags, explicit commits, other branches, unrelated + repositories, and URLs embedded inside other URLs retain their targets. + +CI takes the current repository and default branch from the workflow event. +When running in another repository, a preparation step also queries the public +repository's advertised default branch once. Hook invocations use the prepared +metadata without further discovery requests. The PR base branch and current +working branch do not determine which source URLs are remapped. + +Local hooks use cached remote `HEAD` references and make no discovery requests. +The `upstream` remote identifies the checkout's repository when present; +otherwise `origin` does. To select it explicitly, including when using other +remote names: + +```bash +git config lychee.repository OWNER/REPOSITORY +``` + +The helper matches remote URLs to find each selected repository's metadata. +If a cached default is missing or conflicting, it reports the remote to +refresh, for example: + +```bash +git remote set-head upstream --auto +``` + +A synchronized checkout also needs a remote for the public repository, with +its default cached in the same way, for example `git remote set-head public_repo +--auto`. Fetch the default branch first if Git requests it. Refresh these +references after a repository changes its default branch. + +These rules validate the proposed default-branch tree. Before adopting them on +a maintenance branch, choose an explicit policy for links that intentionally +point to a different tree from the maintenance checkout. + ## Temporary Pixi Lockfile Maintenance Pause Pixi lockfile freshness enforcement and automated refreshes are temporarily diff --git a/ci/tools/checkout_link_defaults.py b/ci/tools/checkout_link_defaults.py new file mode 100644 index 00000000000..422990e7733 --- /dev/null +++ b/ci/tools/checkout_link_defaults.py @@ -0,0 +1,190 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Resolve checkout-aware repository links without network access in the hook. + +CI prepares repository metadata once. Developer checkouts use cached remote +HEADs; ``lychee.repository`` selects the checkout's repository explicitly, or +the conventional ``upstream`` then ``origin`` remote supplies its identity. +Other remotes are used only to find metadata for those selected repositories. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +from pathlib import Path +from urllib.parse import quote, urlsplit + +PUBLIC_REPOSITORY = "NVIDIA/cuda-python" +METADATA_ENV = "LYCHEE_REPOSITORY_LINKS" + + +def _git(root: Path, *arguments: str, optional: bool = False) -> str: + result = subprocess.run( # noqa: S603 + ["git", "-C", str(root), *arguments], # noqa: S607 + check=False, + capture_output=True, + text=True, + ) + if optional and result.returncode == 1: + return "" + result.check_returncode() + return result.stdout.strip() + + +def _repository(value: object) -> str: + if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", value): + raise ValueError(f"Expected a GitHub OWNER/REPOSITORY identity, got {value!r}") + return PUBLIC_REPOSITORY if value.casefold() == PUBLIC_REPOSITORY.casefold() else value + + +def _branch(value: object) -> str: + if ( + not isinstance(value, str) + or not value + or value.startswith("-") + or value == "HEAD" + or re.search(r"[\x00-\x20\x7f]", value) + ): + raise ValueError(f"Expected a Git default branch name, got {value!r}") + result = subprocess.run( # noqa: S603 + ["git", "check-ref-format", f"refs/heads/{value}"], # noqa: S607 + check=False, + capture_output=True, + text=True, + ) + if result.returncode: + raise ValueError(f"Expected a Git default branch name, got {value!r}") + return value + + +def _remote_repository(url: str) -> str | None: + if url.startswith("git@github.com:"): + path = url.removeprefix("git@github.com:") + else: + parsed = urlsplit(url) + if parsed.scheme not in {"https", "ssh"} or parsed.hostname != "github.com": + return None + path = parsed.path.lstrip("/") + return _repository(path.rstrip("/").removesuffix(".git")) + + +def _remotes(root: Path) -> dict[str, str]: + remotes = {} + for line in _git(root, "config", "--get-regexp", r"^remote\..*\.url$", optional=True).splitlines(): + key, url = line.split(maxsplit=1) + if repository := _remote_repository(url): + remotes[key.removeprefix("remote.").removesuffix(".url")] = repository + return remotes + + +def _cached_default(root: Path, repository: str, remotes: dict[str, str]) -> str: + names = [name for name, value in remotes.items() if value.casefold() == repository.casefold()] + if not names: + raise ValueError( + f"No GitHub remote is configured for {repository}. Add and fetch one, then run " + "git remote set-head --auto." + ) + branches = set() + for name in names: + target = _git(root, "symbolic-ref", "--quiet", f"refs/remotes/{name}/HEAD", optional=True) + prefix = f"refs/remotes/{name}/" + if target.startswith(prefix) and target != prefix + "HEAD": + branches.add(target.removeprefix(prefix)) + instructions = "; ".join(f"git remote set-head {name} --auto" for name in names) + if not branches: + raise ValueError( + f"The default branch for {repository} is not cached. Run: {instructions}. " + "Fetch the default branch first if Git requests it." + ) + if len(branches) != 1: + raise ValueError(f"Cached default branches disagree for {repository}. Refresh them: {instructions}.") + return _branch(branches.pop()) + + +def repository_defaults(root: Path) -> tuple[str, dict[str, str]]: + """Return the checkout repository and only its/public default-branch targets.""" + if raw := os.environ.get(METADATA_ENV): + try: + metadata = json.loads(raw) + except json.JSONDecodeError as error: + raise ValueError(f"{METADATA_ENV} must contain valid JSON") from error + if not isinstance(metadata, dict) or set(metadata) != {"repository", "defaults"}: + raise ValueError(f"{METADATA_ENV} must contain repository and defaults") + repository = _repository(metadata["repository"]) + defaults = metadata["defaults"] + if not isinstance(defaults, dict) or set(defaults) != {PUBLIC_REPOSITORY, repository}: + raise ValueError(f"{METADATA_ENV} must describe only the checkout and public repositories") + for branch in defaults.values(): + _branch(branch) + return repository, defaults + + remotes = _remotes(root) + explicit = _git(root, "config", "--get", "lychee.repository", optional=True) + repository = _repository(explicit) if explicit else remotes.get("upstream", remotes.get("origin")) + if repository is None: + raise ValueError( + "Cannot identify this checkout's GitHub repository. Configure an upstream/origin remote, " + "or run git config lychee.repository OWNER/REPOSITORY." + ) + defaults = { + value: _cached_default(root, value, remotes) for value in dict.fromkeys((PUBLIC_REPOSITORY, repository)) + } + return repository, defaults + + +def repo_link_remaps(root: Path, *, rendered: bool = False) -> list[str]: + """Map default-branch URLs to local sources or GitHub's checked-out commit.""" + repository, defaults = repository_defaults(root) + if rendered: + commit = _git(root, "rev-parse", "HEAD") + server = os.environ.get("GITHUB_SERVER_URL", "https://github.com").rstrip("/") + prefix = f"{server}/{repository}" + else: + prefix = root.resolve().as_uri() + arguments = [] + for source, branch in defaults.items(): + for view in ("blob", "tree"): + replacement = f"{prefix}/{view}/{commit}/" if rendered else f"{prefix}/" + # Git branch names may contain URL delimiters or non-ASCII text. + pattern = rf"^https://github[.]com/{re.escape(source)}/{view}/{re.escape(quote(branch, safe='/'))}/" + arguments.extend(["--remap", f"{pattern} {replacement}"]) + return arguments + + +def prepare_ci(repository: str, default_branch: str) -> dict[str, object]: + """Prepare event metadata, querying public HEAD once for other repositories.""" + repository = _repository(repository) + default_branch = _branch(default_branch) + defaults = {repository: default_branch} + if repository != PUBLIC_REPOSITORY: + result = subprocess.run( # noqa: S603 + ["git", "ls-remote", "--symref", f"https://github.com/{PUBLIC_REPOSITORY}.git", "HEAD"], # noqa: S607 + check=True, + capture_output=True, + text=True, + ) + branches = re.findall(r"^ref: refs/heads/(.+)\tHEAD$", result.stdout, flags=re.MULTILINE) + if len(branches) != 1: + raise ValueError(f"Could not identify the advertised default branch for {PUBLIC_REPOSITORY}") + defaults[PUBLIC_REPOSITORY] = _branch(branches[0]) + return {"repository": repository, "defaults": defaults} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", required=True) + parser.add_argument("--default-branch", required=True) + parser.add_argument("--github-env", type=Path, required=True) + arguments = parser.parse_args() + metadata = prepare_ci(arguments.repository, arguments.default_branch) + with arguments.github_env.open("a", encoding="utf-8") as stream: + stream.write(f"{METADATA_ENV}={json.dumps(metadata, separators=(',', ':'))}\n") + + +if __name__ == "__main__": + main() diff --git a/ci/tools/run_lychee.py b/ci/tools/run_lychee.py index 84fb8684c60..877726b34a8 100644 --- a/ci/tools/run_lychee.py +++ b/ci/tools/run_lychee.py @@ -21,6 +21,8 @@ import uuid from pathlib import Path +from checkout_link_defaults import repo_link_remaps + ROOT = Path(__file__).resolve().parents[2] @@ -82,7 +84,9 @@ def main(arguments: list[str] | None = None) -> int: report = directory / f"{uuid.uuid4().hex}.json" arguments = ["--format", "json", "--output", str(report), *arguments] - code = subprocess.run([str(executable), *arguments], check=False, **options).returncode # noqa: S603 + code = subprocess.run( # noqa: S603 + [str(executable), *repo_link_remaps(ROOT, rendered=rendered), *arguments], check=False, **options + ).returncode if report is not None: report.with_suffix(".status.json").write_text(json.dumps({"exit_code": code}), encoding="utf-8") return code diff --git a/ci/tools/tests/test_checkout_link_defaults.py b/ci/tools/tests/test_checkout_link_defaults.py new file mode 100644 index 00000000000..1e43b34cee4 --- /dev/null +++ b/ci/tools/tests/test_checkout_link_defaults.py @@ -0,0 +1,219 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch +from urllib.parse import quote + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import checkout_link_defaults as links + + +def git(root, *arguments): + return subprocess.run( # noqa: S603 + ["git", "-C", str(root), *arguments], # noqa: S607 + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def checkout(tmp_path): + root = tmp_path / "checkout space$#" + root.mkdir() + git(root, "init") + return root + + +def remote(root, alias, url, branch=None): + git(root, "remote", "add", alias, url) + if branch is not None: + git(root, "symbolic-ref", f"refs/remotes/{alias}/HEAD", f"refs/remotes/{alias}/{branch}") + + +def remap(arguments, url): + for argument in arguments[1::2]: + pattern, replacement = argument.split() + url = re.sub(pattern, replacement, url) + return url + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_local_public_default_uses_repository_url_and_keeps_url_suffix(tmp_path, monkeypatch): + monkeypatch.delenv(links.METADATA_ENV, raising=False) + root = checkout(tmp_path) + remote(root, "canonical", "ssh://git@github.com/NVIDIA/cuda-python.git", "next.toolkit") + remote(root, "origin", "git@github.com:rwgk/cuda-python.git", "personal") + git(root, "config", "lychee.repository", links.PUBLIC_REPOSITORY) + arguments = links.repo_link_remaps(root) + for view in ("blob", "tree"): + url = f"https://github.com/NVIDIA/cuda-python/{view}/next.toolkit/docs/a%20guide.md?plain=1#section" + assert remap(arguments, url) == f"{root.as_uri()}/docs/a%20guide.md?plain=1#section" + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_private_checkout_maps_public_and_private_defaults_only(tmp_path, monkeypatch): + monkeypatch.delenv(links.METADATA_ENV, raising=False) + root = checkout(tmp_path) + remote(root, "upstream", "git@github.com:Example/toolkit-private.git", "next/toolkit") + remote(root, "public_repo", "https://github.com/NVIDIA/cuda-python.git", "trunk") + remote(root, "origin", "https://github.com/developer/toolkit-private.git", "fork") + arguments = links.repo_link_remaps(root) + for repository, branch in (("Example/toolkit-private", "next/toolkit"), (links.PUBLIC_REPOSITORY, "trunk")): + url = f"https://github.com/{repository}/tree/{branch}/cuda_bindings" + assert remap(arguments, url) == f"{root.as_uri()}/cuda_bindings" + fork_url = "https://github.com/developer/toolkit-private/tree/fork/README.md" + assert remap(arguments, fork_url) == fork_url + + +@pytest.mark.parametrize( + "url", + [ + "https://github.com/NVIDIA/cuda-python/blob/12.9.x/README.md", + "https://github.com/NVIDIA/cuda-python/tree/v13.0.0/cuda_bindings", + "https://github.com/NVIDIA/cuda-python/blob/0123456789abcdef/README.md", + "https://github.com/NVIDIA/cuda-python/blob/trunkish/README.md", + "https://github.com/NVIDIA/cuda-python/tree/trunk", + "https://github.com/rwgk/cuda-python/blob/trunk/README.md", + "https://github.com/NVIDIA/other-repository/tree/trunk/docs", + "http://github.com/NVIDIA/cuda-python/blob/trunk/README.md", + "https://example.com/redirect?url=https://github.com/NVIDIA/cuda-python/blob/trunk/README.md", + ], +) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_remaps_preserve_other_targets(tmp_path, monkeypatch, url): + monkeypatch.setenv( + links.METADATA_ENV, + json.dumps({"repository": links.PUBLIC_REPOSITORY, "defaults": {links.PUBLIC_REPOSITORY: "trunk"}}), + ) + assert remap(links.repo_link_remaps(tmp_path), url) == url + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_ci_metadata_uses_actual_commit_instead_of_pr_base_or_branch(tmp_path, monkeypatch): + root = checkout(tmp_path) + git( + root, + "-c", + "user.name=Link Test", + "-c", + "user.email=link-test@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "Test checked-out revision", + ) + commit = git(root, "rev-parse", "HEAD") + repository = "Example/toolkit-private" + metadata = {"repository": repository, "defaults": {repository: "next/toolkit", links.PUBLIC_REPOSITORY: "trunk"}} + monkeypatch.setenv(links.METADATA_ENV, json.dumps(metadata)) + monkeypatch.setenv("GITHUB_BASE_REF", "12.9.x") + monkeypatch.setenv("GITHUB_REF_NAME", "123/merge") + monkeypatch.setenv("GITHUB_SERVER_URL", "https://github.com") + arguments = links.repo_link_remaps(root, rendered=True) + for source, branch in metadata["defaults"].items(): + url = f"https://github.com/{source}/blob/{branch}/README.md?plain=1#heading" + assert remap(arguments, url) == f"https://github.com/{repository}/blob/{commit}/README.md?plain=1#heading" + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_missing_cached_head_gives_matching_remote_refresh_instruction(tmp_path, monkeypatch): + monkeypatch.delenv(links.METADATA_ENV, raising=False) + root = checkout(tmp_path) + remote(root, "upstream", "https://github.com/NVIDIA/cuda-python.git") + with pytest.raises(ValueError, match="git remote set-head upstream --auto"): + links.repository_defaults(root) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_conflicting_cached_heads_fail_instead_of_choosing_arbitrarily(tmp_path, monkeypatch): + monkeypatch.delenv(links.METADATA_ENV, raising=False) + root = checkout(tmp_path) + remote(root, "upstream", "https://github.com/NVIDIA/cuda-python.git", "old") + remote(root, "public", "git@github.com:NVIDIA/cuda-python.git", "new") + with pytest.raises(ValueError, match="Cached default branches disagree"): + links.repository_defaults(root) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_prepare_public_ci_uses_event_default_without_network(): + with patch("checkout_link_defaults.subprocess.run", return_value=subprocess.CompletedProcess([], 0)) as run: + metadata = links.prepare_ci(links.PUBLIC_REPOSITORY, "trunk") + assert all("ls-remote" not in call.args[0] for call in run.call_args_list) + assert metadata == {"repository": links.PUBLIC_REPOSITORY, "defaults": {links.PUBLIC_REPOSITORY: "trunk"}} + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_prepare_other_ci_queries_only_public_advertised_head(): + output = "ref: refs/heads/trunk\tHEAD\n" + "a" * 40 + "\tHEAD\n" + with patch( + "checkout_link_defaults.subprocess.run", return_value=subprocess.CompletedProcess([], 0, stdout=output) + ) as run: + metadata = links.prepare_ci("Example/toolkit-private", "next/toolkit") + assert metadata["defaults"] == {"Example/toolkit-private": "next/toolkit", links.PUBLIC_REPOSITORY: "trunk"} + network_calls = [call.args[0] for call in run.call_args_list if "ls-remote" in call.args[0]] + assert network_calls == [ + [ + "git", + "ls-remote", + "--symref", + "https://github.com/NVIDIA/cuda-python.git", + "HEAD", + ] + ] + + +@pytest.mark.parametrize( + "output", ["", "a" * 40 + "\tHEAD\n", "ref: refs/heads/one\tHEAD\nref: refs/heads/two\tHEAD\n"] +) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_prepare_rejects_missing_or_ambiguous_public_default(output): + with ( + patch("checkout_link_defaults.subprocess.run", return_value=subprocess.CompletedProcess([], 0, stdout=output)), + pytest.raises(ValueError, match="advertised default branch"), + ): + links.prepare_ci("Example/toolkit-private", "next/toolkit") + + +@pytest.mark.parametrize( + ("raw", "message"), + [ + ("not json", f"{links.METADATA_ENV} must contain valid JSON"), + ("[]", f"{links.METADATA_ENV} must contain repository and defaults"), + ("{}", f"{links.METADATA_ENV} must contain repository and defaults"), + ('{"repository":null,"defaults":{}}', "OWNER/REPOSITORY"), + ('{"repository":"NVIDIA/cuda-python","defaults":{}}', "only the checkout and public"), + ('{"repository":"NVIDIA/cuda-python","defaults":[]}', "only the checkout and public"), + ('{"repository":"NVIDIA/cuda-python","defaults":{"NVIDIA/cuda-python":""}}', "Git default branch"), + ('{"repository":"NVIDIA/cuda-python","defaults":{"NVIDIA/cuda-python":null}}', "Git default branch"), + ('{"repository":"NVIDIA/cuda-python","defaults":{"NVIDIA/cuda-python":"bad..branch"}}', "Git default branch"), + ( + '{"repository":"NVIDIA/cuda-python","defaults":{"NVIDIA/cuda-python":"bad\\u0000branch"}}', + "Git default branch", + ), + ], +) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_invalid_ci_metadata_fails_explicitly(tmp_path, monkeypatch, raw, message): + monkeypatch.setenv(links.METADATA_ENV, raw) + with pytest.raises(ValueError, match=message): + links.repository_defaults(tmp_path) + + +@pytest.mark.parametrize("branch", ["next#toolkit", "next/\u03b2", "next%toolkit", "next$toolkit"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_valid_branch_names_are_url_encoded_before_matching(tmp_path, monkeypatch, branch): + metadata = {"repository": links.PUBLIC_REPOSITORY, "defaults": {links.PUBLIC_REPOSITORY: branch}} + monkeypatch.setenv(links.METADATA_ENV, json.dumps(metadata)) + url = f"https://github.com/NVIDIA/cuda-python/blob/{quote(branch, safe='/')}/README.md#heading" + assert remap(links.repo_link_remaps(tmp_path), url) == f"{tmp_path.as_uri()}/README.md#heading" diff --git a/ci/tools/tests/test_run_lychee.py b/ci/tools/tests/test_run_lychee.py index 81af33cd11d..eee51dc0d87 100644 --- a/ci/tools/tests/test_run_lychee.py +++ b/ci/tools/tests/test_run_lychee.py @@ -24,6 +24,7 @@ def test_native_reports_preserve_each_precommit_chunk_exit_code(tmp_path, monkey with ( patch("run_lychee.sysconfig.get_path", return_value=str(tmp_path)), patch("run_lychee.sys.platform", "linux"), + patch("run_lychee.repo_link_remaps", return_value=[]), patch( "run_lychee.subprocess.run", side_effect=[subprocess.CompletedProcess([], 2), subprocess.CompletedProcess([], 0)], @@ -57,6 +58,7 @@ def test_rendered_hook_checks_untracked_html_in_its_own_cache(tmp_path, monkeypa with ( patch("run_lychee.sysconfig.get_path", return_value=str(tmp_path)), patch("run_lychee.sys.platform", "linux"), + patch("run_lychee.repo_link_remaps", return_value=[]), patch("run_lychee.subprocess.run", return_value=subprocess.CompletedProcess([], 0)) as run, ): assert main(["--rendered", "--cache"]) == 0 @@ -83,6 +85,7 @@ def test_rendered_hook_rejects_missing_build_before_invoking_native_checker(tmp_ with ( patch("run_lychee.sysconfig.get_path", return_value=str(tmp_path)), patch("run_lychee.sys.platform", "linux"), + patch("run_lychee.repo_link_remaps", return_value=[]), patch("run_lychee.subprocess.run") as run, pytest.raises(ValueError, match="No documentation inputs"), ): From f4df37fb0bd05af859ba3dc200bbc3a00b87f74b Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Fri, 16 Oct 2026 17:07:30 -0700 Subject: [PATCH 2/6] ci: reject dangling cached repository default branches Signed-off-by: Ralf W. Grosse-Kunstleve --- ci/tools/checkout_link_defaults.py | 6 ++++- ci/tools/tests/test_checkout_link_defaults.py | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/ci/tools/checkout_link_defaults.py b/ci/tools/checkout_link_defaults.py index 422990e7733..197589be072 100644 --- a/ci/tools/checkout_link_defaults.py +++ b/ci/tools/checkout_link_defaults.py @@ -93,7 +93,11 @@ def _cached_default(root: Path, repository: str, remotes: dict[str, str]) -> str for name in names: target = _git(root, "symbolic-ref", "--quiet", f"refs/remotes/{name}/HEAD", optional=True) prefix = f"refs/remotes/{name}/" - if target.startswith(prefix) and target != prefix + "HEAD": + if ( + target.startswith(prefix) + and target != prefix + "HEAD" + and _git(root, "rev-parse", "--verify", "--quiet", target, optional=True) + ): branches.add(target.removeprefix(prefix)) instructions = "; ".join(f"git remote set-head {name} --auto" for name in names) if not branches: diff --git a/ci/tools/tests/test_checkout_link_defaults.py b/ci/tools/tests/test_checkout_link_defaults.py index 1e43b34cee4..b8007b5d4ff 100644 --- a/ci/tools/tests/test_checkout_link_defaults.py +++ b/ci/tools/tests/test_checkout_link_defaults.py @@ -30,12 +30,26 @@ def checkout(tmp_path): root = tmp_path / "checkout space$#" root.mkdir() git(root, "init") + git( + root, + "-c", + "user.name=Link Test", + "-c", + "user.email=link-test@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "Initial test revision", + ) return root def remote(root, alias, url, branch=None): git(root, "remote", "add", alias, url) if branch is not None: + git(root, "update-ref", f"refs/remotes/{alias}/{branch}", "HEAD") git(root, "symbolic-ref", f"refs/remotes/{alias}/HEAD", f"refs/remotes/{alias}/{branch}") @@ -135,6 +149,16 @@ def test_missing_cached_head_gives_matching_remote_refresh_instruction(tmp_path, links.repository_defaults(root) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_dangling_cached_head_requires_refresh_instead_of_using_removed_branch(tmp_path, monkeypatch): + monkeypatch.delenv(links.METADATA_ENV, raising=False) + root = checkout(tmp_path) + remote(root, "upstream", "https://github.com/NVIDIA/cuda-python.git") + git(root, "symbolic-ref", "refs/remotes/upstream/HEAD", "refs/remotes/upstream/removed") + with pytest.raises(ValueError, match="git remote set-head upstream --auto"): + links.repository_defaults(root) + + @pytest.mark.agent_authored(model="gpt-6.1-sol") def test_conflicting_cached_heads_fail_instead_of_choosing_arbitrarily(tmp_path, monkeypatch): monkeypatch.delenv(links.METADATA_ENV, raising=False) From 1d241454deeb8008c0d9f3b5145eeac7a1c6eed9 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Mon, 19 Oct 2026 16:23:12 -0700 Subject: [PATCH 3/6] Simplify Lychee source-link remapping Signed-off-by: Ralf W. Grosse-Kunstleve --- .github/actions/lychee-links/action.yml | 2 +- .github/workflows/build-docs.yml | 15 +- .github/workflows/lychee.yml | 12 - .github/workflows/pre-commit.yml | 12 - ci/README.md | 89 +++---- ci/tools/build_docs_for_link_check.sh | 5 +- ci/tools/checkout_link_defaults.py | 194 -------------- ci/tools/checkout_source_links.py | 104 ++++++++ ci/tools/run_lychee.py | 2 +- .../tests/test_build_docs_for_link_check.py | 46 +++- ci/tools/tests/test_checkout_link_defaults.py | 243 ------------------ ci/tools/tests/test_checkout_source_links.py | 164 ++++++++++++ 12 files changed, 357 insertions(+), 531 deletions(-) delete mode 100644 ci/tools/checkout_link_defaults.py create mode 100644 ci/tools/checkout_source_links.py delete mode 100644 ci/tools/tests/test_checkout_link_defaults.py create mode 100644 ci/tools/tests/test_checkout_source_links.py diff --git a/.github/actions/lychee-links/action.yml b/.github/actions/lychee-links/action.yml index 85273c16543..45ba88fc772 100644 --- a/.github/actions/lychee-links/action.yml +++ b/.github/actions/lychee-links/action.yml @@ -69,7 +69,7 @@ runs: KIND: ${{ inputs.kind }} # Anything that can change a check result or the cache's meaning. The # hook configuration carries the native wheel pin and every hook option. - POLICY_HASH: ${{ hashFiles('.pre-commit-config.yaml', 'lychee.toml', 'ci/tools/run_lychee.py', 'ci/tools/run_lychee_precommit.py', 'ci/tools/retry_lychee.py', 'ci/tools/checkout_link_defaults.py') }} + POLICY_HASH: ${{ hashFiles('.pre-commit-config.yaml', 'lychee.toml', 'ci/tools/run_lychee.py', 'ci/tools/run_lychee_precommit.py', 'ci/tools/retry_lychee.py', 'ci/tools/checkout_source_links.py') }} run: | # Authored and rendered results stay apart: only rendered checks validate # fragments, and Lychee's cache does not record which validation ran. diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index e7716137e03..46ddbcf8d84 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -118,7 +118,7 @@ jobs: fi else FILE_HASH="${{ github.sha }}" - DOCS_GITHUB_REF="${{ github.sha }}" + DOCS_GITHUB_REF=$(python ci/tools/checkout_source_links.py --github-ref) fi # make outputs from the previous job as env vars @@ -295,19 +295,6 @@ jobs: with: python-version: "3.14" - - name: Discover repository link targets - if: ${{ !inputs.is-release && startsWith(github.ref_name, 'pull-request/') }} - # BEGIN SYNCED LYCHEE REPOSITORY METADATA - env: - LYCHEE_REPOSITORY: ${{ github.repository }} - LYCHEE_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: >- - python ci/tools/checkout_link_defaults.py - --repository "$LYCHEE_REPOSITORY" - --default-branch "$LYCHEE_DEFAULT_BRANCH" - --github-env "$GITHUB_ENV" - # END SYNCED LYCHEE REPOSITORY METADATA - - name: Check rendered documentation links through pre-commit if: ${{ !inputs.is-release && startsWith(github.ref_name, 'pull-request/') }} timeout-minutes: 60 diff --git a/.github/workflows/lychee.yml b/.github/workflows/lychee.yml index 2356e74daf7..6a3f519ce9b 100644 --- a/.github/workflows/lychee.yml +++ b/.github/workflows/lychee.yml @@ -75,18 +75,6 @@ jobs: - name: Set pre-commit cache location run: echo "PRE_COMMIT_HOME=${RUNNER_TEMP}/pre-commit" >> "${GITHUB_ENV}" - - name: Discover repository link targets - # BEGIN SYNCED LYCHEE REPOSITORY METADATA - env: - LYCHEE_REPOSITORY: ${{ github.repository }} - LYCHEE_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: >- - python ci/tools/checkout_link_defaults.py - --repository "$LYCHEE_REPOSITORY" - --default-branch "$LYCHEE_DEFAULT_BRANCH" - --github-env "$GITHUB_ENV" - # END SYNCED LYCHEE REPOSITORY METADATA - - name: Restore pre-commit environments uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index b95a9458ca9..a0cf2aa0b49 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -70,18 +70,6 @@ jobs: - name: Set pre-commit cache location run: echo "PRE_COMMIT_HOME=${RUNNER_TEMP}/pre-commit" >> "${GITHUB_ENV}" - - name: Discover repository link targets - # BEGIN SYNCED LYCHEE REPOSITORY METADATA - env: - LYCHEE_REPOSITORY: ${{ github.repository }} - LYCHEE_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: >- - python ci/tools/checkout_link_defaults.py - --repository "$LYCHEE_REPOSITORY" - --default-branch "$LYCHEE_DEFAULT_BRANCH" - --github-env "$GITHUB_ENV" - # END SYNCED LYCHEE REPOSITORY METADATA - - name: Restore pre-commit cache id: precommit-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/ci/README.md b/ci/README.md index 170ebfa0041..1a98d4e969e 100644 --- a/ci/README.md +++ b/ci/README.md @@ -30,9 +30,9 @@ Two pre-commit hooks check links with one pinned native wheel (`lychee-bin`, see `.pre-commit-config.yaml`) through the wrapper `ci/tools/run_lychee.py`: `lychee` checks authored Markdown and reST (fragments unchecked), and the manual-stage `lychee-rendered` checks the generated HTML in `artifacts/docs`, -including fragments. Developers run the same hooks locally. Links to supported -repositories' default branches are checked against the proposed checkout, as -described under [Repository Links](#repository-links). +including fragments. Developers run the same hooks locally. Public repository +`main` links are checked against the proposed checkout, as described under +[Repository Links](#repository-links). CI runs a hook through `ci/tools/run_lychee_precommit.py `, wrapped with the shared cache and report handling in @@ -98,7 +98,7 @@ can stay unnoticed for up to 72 hours. The action keys each baseline by checker kind and a hash of `.pre-commit-config.yaml` (hook options and wheel pin), `lychee.toml`, and `ci/tools/run_lychee.py`, `run_lychee_precommit.py`, `retry_lychee.py`, and -`checkout_link_defaults.py`, so editing any of them starts a cold cache. +`checkout_source_links.py`, so editing any of them starts a cold cache. Authored and rendered caches stay apart because only rendered checks validate fragments and Lychee's cache does not record which validation ran. URL results are shared across operating systems; pre-commit's installed environments keep @@ -107,58 +107,49 @@ their OS- and Python-specific caches. ### Repository Links Documentation can link to a path added or moved in the same PR using its -intended default-branch URL. For example, a URL ending in -`tree//new-directory` is validated before that directory -exists on the remote default branch. Lychee remaps the -validation target; source files and published documentation keep their URLs. - -`ci/tools/checkout_link_defaults.py` discovers each source repository's default -branch, rather than assuming it is named `main`. It supports the canonical -public repository and the repository being checked. A synchronized repository -with a different default branch, such as `ctk-next`, can retain public source -links and also use its own repository links. Each source URL is matched against -the default branch of the repository named in that URL. +intended public URL, for example +`https://github.com/NVIDIA/cuda-python/tree/main/new-directory`. Lychee +validates the proposed target before it exists on public `main`. Only the +validation target changes; source files and built documentation retain their URLs. -- Authored Markdown and reST map to file URIs in the working tree, including - uncommitted and unpushed paths. Missing files and directories fail even if a - prior check succeeded. Authored checks continue to omit fragment validation. -- Rendered HTML maps to GitHub's file or directory view at the actual - checked-out commit in the current repository, preserving fragment checks. -- Only complete URL prefixes naming a supported repository and its default - branch are remapped. Tags, explicit commits, other branches, unrelated - repositories, and URLs embedded inside other URLs retain their targets. - -CI takes the current repository and default branch from the workflow event. -When running in another repository, a preparation step also queries the public -repository's advertised default branch once. Hook invocations use the prepared -metadata without further discovery requests. The PR base branch and current -working branch do not determine which source URLs are remapped. - -Local hooks use cached remote `HEAD` references and make no discovery requests. -The `upstream` remote identifies the checkout's repository when present; -otherwise `origin` does. To select it explicitly, including when using other -remote names: +`ci/tools/checkout_source_links.py` matches the complete public repository +prefix followed by `blob/main/` or `tree/main/`: -```bash -git config lychee.repository OWNER/REPOSITORY -``` - -The helper matches remote URLs to find each selected repository's metadata. -If a cached default is missing or conflicting, it reports the remote to -refresh, for example: +- Authored Markdown and reST map to file URIs in the working tree, including + uncommitted and unpushed paths. Missing files and directories fail even after + an earlier success. These checks need no repository metadata and retain the + existing policy of omitting fragments. +- Rendered HTML from the public repository maps source links to GitHub's file + or directory view at the actual checked-out commit, retaining fragment checks. + That commit must be available on GitHub. +- Rendered HTML from other repositories maps the same public source links to + local files and directories. Only those source links omit fragments, since + local files do not reproduce every GitHub source-view anchor. Generated + documentation pages and their anchors still receive full validation. + +Tags, explicit commits, other branches, unrelated repositories, and URLs embedded +inside other URLs retain their original targets. There is no default-branch +lookup or remote `HEAD` requirement. A future change to the public URL convention +would require an explicit update to these matching rules. + +For rendered checks and checking builds, CI supplies the checkout identity +through `GITHUB_REPOSITORY`. Local runs use `git config lychee.repository`, or +otherwise the `upstream` remote followed by `origin`. A checkout whose remotes +only name a fork can select public-repository validation explicitly: ```bash -git remote set-head upstream --auto +git config lychee.repository NVIDIA/cuda-python ``` -A synchronized checkout also needs a remote for the public repository, with -its default cached in the same way, for example `git remote set-head public_repo ---auto`. Fetch the default branch first if Git requests it. Refresh these -references after a repository changes its default branch. +The checking build uses the public checkout's actual commit for generated +example links. In other repositories it uses `main`, allowing those public URLs +to receive the same local target checks without inserting another repository's +commit into public links. An explicit `CUDA_PYTHON_DOCS_GITHUB_REF` override is +retained; release builds continue to use their release tags. -These rules validate the proposed default-branch tree. Before adopting them on -a maintenance branch, choose an explicit policy for links that intentionally -point to a different tree from the maintenance checkout. +These rules validate the proposed source tree. Before adopting them on a +maintenance branch, choose an explicit policy for links that intentionally point +to a different tree from the maintenance checkout. ## Temporary Pixi Lockfile Maintenance Pause diff --git a/ci/tools/build_docs_for_link_check.sh b/ci/tools/build_docs_for_link_check.sh index 32c781bf1ad..d13cec4a36f 100644 --- a/ci/tools/build_docs_for_link_check.sh +++ b/ci/tools/build_docs_for_link_check.sh @@ -15,7 +15,10 @@ if [[ "${PIXI_ENVIRONMENT_NAME:-}" != "docs" ]]; then exit 1 fi -export CUDA_PYTHON_DOCS_GITHUB_REF="${CUDA_PYTHON_DOCS_GITHUB_REF:-$(git rev-parse HEAD)}" +if [[ -z "${CUDA_PYTHON_DOCS_GITHUB_REF:-}" ]]; then + CUDA_PYTHON_DOCS_GITHUB_REF=$(python ci/tools/checkout_source_links.py --github-ref) +fi +export CUDA_PYTHON_DOCS_GITHUB_REF export BUILD_LATEST=1 export BUILD_PREVIEW=0 diff --git a/ci/tools/checkout_link_defaults.py b/ci/tools/checkout_link_defaults.py deleted file mode 100644 index 197589be072..00000000000 --- a/ci/tools/checkout_link_defaults.py +++ /dev/null @@ -1,194 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Resolve checkout-aware repository links without network access in the hook. - -CI prepares repository metadata once. Developer checkouts use cached remote -HEADs; ``lychee.repository`` selects the checkout's repository explicitly, or -the conventional ``upstream`` then ``origin`` remote supplies its identity. -Other remotes are used only to find metadata for those selected repositories. -""" - -from __future__ import annotations - -import argparse -import json -import os -import re -import subprocess -from pathlib import Path -from urllib.parse import quote, urlsplit - -PUBLIC_REPOSITORY = "NVIDIA/cuda-python" -METADATA_ENV = "LYCHEE_REPOSITORY_LINKS" - - -def _git(root: Path, *arguments: str, optional: bool = False) -> str: - result = subprocess.run( # noqa: S603 - ["git", "-C", str(root), *arguments], # noqa: S607 - check=False, - capture_output=True, - text=True, - ) - if optional and result.returncode == 1: - return "" - result.check_returncode() - return result.stdout.strip() - - -def _repository(value: object) -> str: - if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", value): - raise ValueError(f"Expected a GitHub OWNER/REPOSITORY identity, got {value!r}") - return PUBLIC_REPOSITORY if value.casefold() == PUBLIC_REPOSITORY.casefold() else value - - -def _branch(value: object) -> str: - if ( - not isinstance(value, str) - or not value - or value.startswith("-") - or value == "HEAD" - or re.search(r"[\x00-\x20\x7f]", value) - ): - raise ValueError(f"Expected a Git default branch name, got {value!r}") - result = subprocess.run( # noqa: S603 - ["git", "check-ref-format", f"refs/heads/{value}"], # noqa: S607 - check=False, - capture_output=True, - text=True, - ) - if result.returncode: - raise ValueError(f"Expected a Git default branch name, got {value!r}") - return value - - -def _remote_repository(url: str) -> str | None: - if url.startswith("git@github.com:"): - path = url.removeprefix("git@github.com:") - else: - parsed = urlsplit(url) - if parsed.scheme not in {"https", "ssh"} or parsed.hostname != "github.com": - return None - path = parsed.path.lstrip("/") - return _repository(path.rstrip("/").removesuffix(".git")) - - -def _remotes(root: Path) -> dict[str, str]: - remotes = {} - for line in _git(root, "config", "--get-regexp", r"^remote\..*\.url$", optional=True).splitlines(): - key, url = line.split(maxsplit=1) - if repository := _remote_repository(url): - remotes[key.removeprefix("remote.").removesuffix(".url")] = repository - return remotes - - -def _cached_default(root: Path, repository: str, remotes: dict[str, str]) -> str: - names = [name for name, value in remotes.items() if value.casefold() == repository.casefold()] - if not names: - raise ValueError( - f"No GitHub remote is configured for {repository}. Add and fetch one, then run " - "git remote set-head --auto." - ) - branches = set() - for name in names: - target = _git(root, "symbolic-ref", "--quiet", f"refs/remotes/{name}/HEAD", optional=True) - prefix = f"refs/remotes/{name}/" - if ( - target.startswith(prefix) - and target != prefix + "HEAD" - and _git(root, "rev-parse", "--verify", "--quiet", target, optional=True) - ): - branches.add(target.removeprefix(prefix)) - instructions = "; ".join(f"git remote set-head {name} --auto" for name in names) - if not branches: - raise ValueError( - f"The default branch for {repository} is not cached. Run: {instructions}. " - "Fetch the default branch first if Git requests it." - ) - if len(branches) != 1: - raise ValueError(f"Cached default branches disagree for {repository}. Refresh them: {instructions}.") - return _branch(branches.pop()) - - -def repository_defaults(root: Path) -> tuple[str, dict[str, str]]: - """Return the checkout repository and only its/public default-branch targets.""" - if raw := os.environ.get(METADATA_ENV): - try: - metadata = json.loads(raw) - except json.JSONDecodeError as error: - raise ValueError(f"{METADATA_ENV} must contain valid JSON") from error - if not isinstance(metadata, dict) or set(metadata) != {"repository", "defaults"}: - raise ValueError(f"{METADATA_ENV} must contain repository and defaults") - repository = _repository(metadata["repository"]) - defaults = metadata["defaults"] - if not isinstance(defaults, dict) or set(defaults) != {PUBLIC_REPOSITORY, repository}: - raise ValueError(f"{METADATA_ENV} must describe only the checkout and public repositories") - for branch in defaults.values(): - _branch(branch) - return repository, defaults - - remotes = _remotes(root) - explicit = _git(root, "config", "--get", "lychee.repository", optional=True) - repository = _repository(explicit) if explicit else remotes.get("upstream", remotes.get("origin")) - if repository is None: - raise ValueError( - "Cannot identify this checkout's GitHub repository. Configure an upstream/origin remote, " - "or run git config lychee.repository OWNER/REPOSITORY." - ) - defaults = { - value: _cached_default(root, value, remotes) for value in dict.fromkeys((PUBLIC_REPOSITORY, repository)) - } - return repository, defaults - - -def repo_link_remaps(root: Path, *, rendered: bool = False) -> list[str]: - """Map default-branch URLs to local sources or GitHub's checked-out commit.""" - repository, defaults = repository_defaults(root) - if rendered: - commit = _git(root, "rev-parse", "HEAD") - server = os.environ.get("GITHUB_SERVER_URL", "https://github.com").rstrip("/") - prefix = f"{server}/{repository}" - else: - prefix = root.resolve().as_uri() - arguments = [] - for source, branch in defaults.items(): - for view in ("blob", "tree"): - replacement = f"{prefix}/{view}/{commit}/" if rendered else f"{prefix}/" - # Git branch names may contain URL delimiters or non-ASCII text. - pattern = rf"^https://github[.]com/{re.escape(source)}/{view}/{re.escape(quote(branch, safe='/'))}/" - arguments.extend(["--remap", f"{pattern} {replacement}"]) - return arguments - - -def prepare_ci(repository: str, default_branch: str) -> dict[str, object]: - """Prepare event metadata, querying public HEAD once for other repositories.""" - repository = _repository(repository) - default_branch = _branch(default_branch) - defaults = {repository: default_branch} - if repository != PUBLIC_REPOSITORY: - result = subprocess.run( # noqa: S603 - ["git", "ls-remote", "--symref", f"https://github.com/{PUBLIC_REPOSITORY}.git", "HEAD"], # noqa: S607 - check=True, - capture_output=True, - text=True, - ) - branches = re.findall(r"^ref: refs/heads/(.+)\tHEAD$", result.stdout, flags=re.MULTILINE) - if len(branches) != 1: - raise ValueError(f"Could not identify the advertised default branch for {PUBLIC_REPOSITORY}") - defaults[PUBLIC_REPOSITORY] = _branch(branches[0]) - return {"repository": repository, "defaults": defaults} - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repository", required=True) - parser.add_argument("--default-branch", required=True) - parser.add_argument("--github-env", type=Path, required=True) - arguments = parser.parse_args() - metadata = prepare_ci(arguments.repository, arguments.default_branch) - with arguments.github_env.open("a", encoding="utf-8") as stream: - stream.write(f"{METADATA_ENV}={json.dumps(metadata, separators=(',', ':'))}\n") - - -if __name__ == "__main__": - main() diff --git a/ci/tools/checkout_source_links.py b/ci/tools/checkout_source_links.py new file mode 100644 index 00000000000..8375418d9ca --- /dev/null +++ b/ci/tools/checkout_source_links.py @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Check public main source links against the proposed checkout. + +Authored checks use local files without consulting Git metadata. Rendered +checks use the tested public commit for GitHub's fragment handling; other +checkouts validate local source paths and leave GitHub-specific fragments +for their eventual public review. +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +from pathlib import Path +from urllib.parse import urlsplit + +PUBLIC_REPOSITORY = "NVIDIA/cuda-python" +SOURCE_URL = r"^https://github[.]com/NVIDIA/cuda-python/(blob|tree)/main/" + + +def _git(root: Path, *arguments: str, optional: bool = False) -> str | None: + result = subprocess.run( # noqa: S603 + ["git", "-C", str(root), *arguments], # noqa: S607 + check=False, + capture_output=True, + text=True, + ) + if optional and result.returncode == 1: + return None + result.check_returncode() + return result.stdout.strip() + + +def _repository(value: str) -> str: + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", value): + raise ValueError("Expected a GitHub OWNER/REPOSITORY identity") + return PUBLIC_REPOSITORY if value.casefold() == PUBLIC_REPOSITORY.casefold() else value + + +def _remote_repository(url: str) -> str | None: + if url.startswith("git@github.com:"): + path = url.removeprefix("git@github.com:") + else: + parsed = urlsplit(url) + if parsed.scheme not in {"https", "ssh"} or parsed.hostname != "github.com": + return None + path = parsed.path.lstrip("/") + return _repository(path.rstrip("/").removesuffix(".git")) + + +def checkout_repository(root: Path) -> str: + """Identify a rendered checkout without querying remote default branches.""" + if "GITHUB_REPOSITORY" in os.environ: + return _repository(os.environ["GITHUB_REPOSITORY"]) + explicit = _git(root, "config", "--get", "lychee.repository", optional=True) + if explicit is not None: + return _repository(explicit) + for name in ("upstream", "origin"): + url = _git(root, "config", "--get", f"remote.{name}.url", optional=True) + if url is not None and (repository := _remote_repository(url)) is not None: + return repository + raise ValueError( + "Cannot identify this checkout's GitHub repository. Configure an upstream/origin remote, " + "or run git config lychee.repository OWNER/REPOSITORY." + ) + + +def github_source_ref(root: Path) -> str: + """Use the tested public commit, or main for links checked against local files.""" + if checkout_repository(root) != PUBLIC_REPOSITORY: + return "main" + commit = _git(root, "rev-parse", "HEAD") + if commit is None or not re.fullmatch(r"[0-9a-fA-F]{40,64}", commit): + raise ValueError("Git did not return a full checkout commit ID") + return commit + + +def repo_link_remaps(root: Path, *, rendered: bool = False) -> list[str]: + """Remap only canonical public main source URLs; keep other targets literal.""" + local = root.resolve().as_uri() + "/" + if not rendered: + return ["--remap", f"{SOURCE_URL} {local}"] + ref = github_source_ref(root) + if ref != "main": + return ["--remap", f"{SOURCE_URL} https://github.com/{PUBLIC_REPOSITORY}/$1/{ref}/"] + # GitHub source fragments do not describe local files. Omit them only on + # these source links; generated HTML keeps its ordinary full anchor checks. + pattern = SOURCE_URL.replace("(blob|tree)", "(?:blob|tree)") + r"([^#]*)(?:#.*)?$" + return ["--remap", f"{pattern} {local}$1"] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--github-ref", action="store_true", required=True) + parser.parse_args() + print(github_source_ref(Path(__file__).resolve().parents[2])) + + +if __name__ == "__main__": + main() diff --git a/ci/tools/run_lychee.py b/ci/tools/run_lychee.py index 877726b34a8..2c0991eb34e 100644 --- a/ci/tools/run_lychee.py +++ b/ci/tools/run_lychee.py @@ -21,7 +21,7 @@ import uuid from pathlib import Path -from checkout_link_defaults import repo_link_remaps +from checkout_source_links import repo_link_remaps ROOT = Path(__file__).resolve().parents[2] diff --git a/ci/tools/tests/test_build_docs_for_link_check.py b/ci/tools/tests/test_build_docs_for_link_check.py index a0a514b3ab9..06b5eeaa903 100644 --- a/ci/tools/tests/test_build_docs_for_link_check.py +++ b/ci/tools/tests/test_build_docs_for_link_check.py @@ -12,16 +12,49 @@ import pytest -BUILD_HELPER = Path(__file__).resolve().parents[1] / "build_docs_for_link_check.sh" +TOOLS = Path(__file__).resolve().parents[1] +BUILD_HELPER = TOOLS / "build_docs_for_link_check.sh" @pytest.mark.parametrize("checkout_name", ["checkout", "checkout with spaces"]) +@pytest.mark.parametrize("repository", ["NVIDIA/cuda-python", "example/project"]) +@pytest.mark.parametrize("explicit_ref", [None, "test-ref"]) @pytest.mark.agent_authored(model="gpt-6") -def test_checking_build_uses_final_local_output_as_docs_domain(tmp_path, monkeypatch, checkout_name): +def test_checking_build_uses_local_output_and_source_ref( + tmp_path, monkeypatch, checkout_name, repository, explicit_ref +): checkout = tmp_path / checkout_name helper = checkout / "ci/tools/build_docs_for_link_check.sh" helper.parent.mkdir(parents=True) shutil.copyfile(BUILD_HELPER, helper) + shutil.copyfile(TOOLS / "checkout_source_links.py", helper.with_name("checkout_source_links.py")) + subprocess.run(["git", "init", str(checkout)], check=True, capture_output=True) # noqa: S603, S607 + subprocess.run( # noqa: S603 + ["git", "-C", str(checkout), "add", "ci"], # noqa: S607 + check=True, + ) + subprocess.run( # noqa: S603 + [ # noqa: S607 + "git", + "-C", + str(checkout), + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "Initial checking-build fixture", + ], + check=True, + capture_output=True, + ) + head = subprocess.check_output( # noqa: S603 + ["git", "-C", str(checkout), "rev-parse", "HEAD"], # noqa: S607 + text=True, + ).strip() # Skip package installation/import checks while running the helper's actual # environment setup and artifact copy. Sphinx is not a CI-tools dependency. @@ -56,7 +89,7 @@ def test_checking_build_uses_final_local_output_as_docs_domain(tmp_path, monkeyp output = Path("build/html") output.mkdir(parents=True) settings = {key: os.environ[key] for key in ( - "CUDA_PYTHON_DOCS_DOMAIN", "BUILD_LATEST", "BUILD_PREVIEW", + "CUDA_PYTHON_DOCS_DOMAIN", "CUDA_PYTHON_DOCS_GITHUB_REF", "BUILD_LATEST", "BUILD_PREVIEW", )} (output / "build-settings.json").write_text(json.dumps(settings), encoding="utf-8") PY @@ -67,7 +100,11 @@ def test_checking_build_uses_final_local_output_as_docs_domain(tmp_path, monkeyp monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") monkeypatch.setenv("TEST_PYTHON", sys.executable) monkeypatch.setenv("PIXI_ENVIRONMENT_NAME", "docs") - monkeypatch.setenv("CUDA_PYTHON_DOCS_GITHUB_REF", "test-ref") + monkeypatch.setenv("GITHUB_REPOSITORY", repository) + if explicit_ref is None: + monkeypatch.delenv("CUDA_PYTHON_DOCS_GITHUB_REF", raising=False) + else: + monkeypatch.setenv("CUDA_PYTHON_DOCS_GITHUB_REF", explicit_ref) monkeypatch.setenv("CUDA_PYTHON_DOCS_DOMAIN", "https://nvidia.github.io/cuda-python") monkeypatch.setenv("BUILD_PREVIEW", "1") @@ -77,6 +114,7 @@ def test_checking_build_uses_final_local_output_as_docs_domain(tmp_path, monkeyp settings = json.loads((output / "build-settings.json").read_text(encoding="utf-8")) assert settings == { "CUDA_PYTHON_DOCS_DOMAIN": output.as_uri(), + "CUDA_PYTHON_DOCS_GITHUB_REF": explicit_ref or (head if repository == "NVIDIA/cuda-python" else "main"), "BUILD_LATEST": "1", "BUILD_PREVIEW": "0", } diff --git a/ci/tools/tests/test_checkout_link_defaults.py b/ci/tools/tests/test_checkout_link_defaults.py deleted file mode 100644 index b8007b5d4ff..00000000000 --- a/ci/tools/tests/test_checkout_link_defaults.py +++ /dev/null @@ -1,243 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -import json -import re -import subprocess -import sys -from pathlib import Path -from unittest.mock import patch -from urllib.parse import quote - -import pytest - -sys.path.insert(0, str(Path(__file__).parent.parent)) -import checkout_link_defaults as links - - -def git(root, *arguments): - return subprocess.run( # noqa: S603 - ["git", "-C", str(root), *arguments], # noqa: S607 - check=True, - capture_output=True, - text=True, - ).stdout.strip() - - -def checkout(tmp_path): - root = tmp_path / "checkout space$#" - root.mkdir() - git(root, "init") - git( - root, - "-c", - "user.name=Link Test", - "-c", - "user.email=link-test@example.invalid", - "-c", - "commit.gpgsign=false", - "commit", - "--allow-empty", - "-m", - "Initial test revision", - ) - return root - - -def remote(root, alias, url, branch=None): - git(root, "remote", "add", alias, url) - if branch is not None: - git(root, "update-ref", f"refs/remotes/{alias}/{branch}", "HEAD") - git(root, "symbolic-ref", f"refs/remotes/{alias}/HEAD", f"refs/remotes/{alias}/{branch}") - - -def remap(arguments, url): - for argument in arguments[1::2]: - pattern, replacement = argument.split() - url = re.sub(pattern, replacement, url) - return url - - -@pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_local_public_default_uses_repository_url_and_keeps_url_suffix(tmp_path, monkeypatch): - monkeypatch.delenv(links.METADATA_ENV, raising=False) - root = checkout(tmp_path) - remote(root, "canonical", "ssh://git@github.com/NVIDIA/cuda-python.git", "next.toolkit") - remote(root, "origin", "git@github.com:rwgk/cuda-python.git", "personal") - git(root, "config", "lychee.repository", links.PUBLIC_REPOSITORY) - arguments = links.repo_link_remaps(root) - for view in ("blob", "tree"): - url = f"https://github.com/NVIDIA/cuda-python/{view}/next.toolkit/docs/a%20guide.md?plain=1#section" - assert remap(arguments, url) == f"{root.as_uri()}/docs/a%20guide.md?plain=1#section" - - -@pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_private_checkout_maps_public_and_private_defaults_only(tmp_path, monkeypatch): - monkeypatch.delenv(links.METADATA_ENV, raising=False) - root = checkout(tmp_path) - remote(root, "upstream", "git@github.com:Example/toolkit-private.git", "next/toolkit") - remote(root, "public_repo", "https://github.com/NVIDIA/cuda-python.git", "trunk") - remote(root, "origin", "https://github.com/developer/toolkit-private.git", "fork") - arguments = links.repo_link_remaps(root) - for repository, branch in (("Example/toolkit-private", "next/toolkit"), (links.PUBLIC_REPOSITORY, "trunk")): - url = f"https://github.com/{repository}/tree/{branch}/cuda_bindings" - assert remap(arguments, url) == f"{root.as_uri()}/cuda_bindings" - fork_url = "https://github.com/developer/toolkit-private/tree/fork/README.md" - assert remap(arguments, fork_url) == fork_url - - -@pytest.mark.parametrize( - "url", - [ - "https://github.com/NVIDIA/cuda-python/blob/12.9.x/README.md", - "https://github.com/NVIDIA/cuda-python/tree/v13.0.0/cuda_bindings", - "https://github.com/NVIDIA/cuda-python/blob/0123456789abcdef/README.md", - "https://github.com/NVIDIA/cuda-python/blob/trunkish/README.md", - "https://github.com/NVIDIA/cuda-python/tree/trunk", - "https://github.com/rwgk/cuda-python/blob/trunk/README.md", - "https://github.com/NVIDIA/other-repository/tree/trunk/docs", - "http://github.com/NVIDIA/cuda-python/blob/trunk/README.md", - "https://example.com/redirect?url=https://github.com/NVIDIA/cuda-python/blob/trunk/README.md", - ], -) -@pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_remaps_preserve_other_targets(tmp_path, monkeypatch, url): - monkeypatch.setenv( - links.METADATA_ENV, - json.dumps({"repository": links.PUBLIC_REPOSITORY, "defaults": {links.PUBLIC_REPOSITORY: "trunk"}}), - ) - assert remap(links.repo_link_remaps(tmp_path), url) == url - - -@pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_ci_metadata_uses_actual_commit_instead_of_pr_base_or_branch(tmp_path, monkeypatch): - root = checkout(tmp_path) - git( - root, - "-c", - "user.name=Link Test", - "-c", - "user.email=link-test@example.invalid", - "-c", - "commit.gpgsign=false", - "commit", - "--allow-empty", - "-m", - "Test checked-out revision", - ) - commit = git(root, "rev-parse", "HEAD") - repository = "Example/toolkit-private" - metadata = {"repository": repository, "defaults": {repository: "next/toolkit", links.PUBLIC_REPOSITORY: "trunk"}} - monkeypatch.setenv(links.METADATA_ENV, json.dumps(metadata)) - monkeypatch.setenv("GITHUB_BASE_REF", "12.9.x") - monkeypatch.setenv("GITHUB_REF_NAME", "123/merge") - monkeypatch.setenv("GITHUB_SERVER_URL", "https://github.com") - arguments = links.repo_link_remaps(root, rendered=True) - for source, branch in metadata["defaults"].items(): - url = f"https://github.com/{source}/blob/{branch}/README.md?plain=1#heading" - assert remap(arguments, url) == f"https://github.com/{repository}/blob/{commit}/README.md?plain=1#heading" - - -@pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_missing_cached_head_gives_matching_remote_refresh_instruction(tmp_path, monkeypatch): - monkeypatch.delenv(links.METADATA_ENV, raising=False) - root = checkout(tmp_path) - remote(root, "upstream", "https://github.com/NVIDIA/cuda-python.git") - with pytest.raises(ValueError, match="git remote set-head upstream --auto"): - links.repository_defaults(root) - - -@pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_dangling_cached_head_requires_refresh_instead_of_using_removed_branch(tmp_path, monkeypatch): - monkeypatch.delenv(links.METADATA_ENV, raising=False) - root = checkout(tmp_path) - remote(root, "upstream", "https://github.com/NVIDIA/cuda-python.git") - git(root, "symbolic-ref", "refs/remotes/upstream/HEAD", "refs/remotes/upstream/removed") - with pytest.raises(ValueError, match="git remote set-head upstream --auto"): - links.repository_defaults(root) - - -@pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_conflicting_cached_heads_fail_instead_of_choosing_arbitrarily(tmp_path, monkeypatch): - monkeypatch.delenv(links.METADATA_ENV, raising=False) - root = checkout(tmp_path) - remote(root, "upstream", "https://github.com/NVIDIA/cuda-python.git", "old") - remote(root, "public", "git@github.com:NVIDIA/cuda-python.git", "new") - with pytest.raises(ValueError, match="Cached default branches disagree"): - links.repository_defaults(root) - - -@pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_prepare_public_ci_uses_event_default_without_network(): - with patch("checkout_link_defaults.subprocess.run", return_value=subprocess.CompletedProcess([], 0)) as run: - metadata = links.prepare_ci(links.PUBLIC_REPOSITORY, "trunk") - assert all("ls-remote" not in call.args[0] for call in run.call_args_list) - assert metadata == {"repository": links.PUBLIC_REPOSITORY, "defaults": {links.PUBLIC_REPOSITORY: "trunk"}} - - -@pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_prepare_other_ci_queries_only_public_advertised_head(): - output = "ref: refs/heads/trunk\tHEAD\n" + "a" * 40 + "\tHEAD\n" - with patch( - "checkout_link_defaults.subprocess.run", return_value=subprocess.CompletedProcess([], 0, stdout=output) - ) as run: - metadata = links.prepare_ci("Example/toolkit-private", "next/toolkit") - assert metadata["defaults"] == {"Example/toolkit-private": "next/toolkit", links.PUBLIC_REPOSITORY: "trunk"} - network_calls = [call.args[0] for call in run.call_args_list if "ls-remote" in call.args[0]] - assert network_calls == [ - [ - "git", - "ls-remote", - "--symref", - "https://github.com/NVIDIA/cuda-python.git", - "HEAD", - ] - ] - - -@pytest.mark.parametrize( - "output", ["", "a" * 40 + "\tHEAD\n", "ref: refs/heads/one\tHEAD\nref: refs/heads/two\tHEAD\n"] -) -@pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_prepare_rejects_missing_or_ambiguous_public_default(output): - with ( - patch("checkout_link_defaults.subprocess.run", return_value=subprocess.CompletedProcess([], 0, stdout=output)), - pytest.raises(ValueError, match="advertised default branch"), - ): - links.prepare_ci("Example/toolkit-private", "next/toolkit") - - -@pytest.mark.parametrize( - ("raw", "message"), - [ - ("not json", f"{links.METADATA_ENV} must contain valid JSON"), - ("[]", f"{links.METADATA_ENV} must contain repository and defaults"), - ("{}", f"{links.METADATA_ENV} must contain repository and defaults"), - ('{"repository":null,"defaults":{}}', "OWNER/REPOSITORY"), - ('{"repository":"NVIDIA/cuda-python","defaults":{}}', "only the checkout and public"), - ('{"repository":"NVIDIA/cuda-python","defaults":[]}', "only the checkout and public"), - ('{"repository":"NVIDIA/cuda-python","defaults":{"NVIDIA/cuda-python":""}}', "Git default branch"), - ('{"repository":"NVIDIA/cuda-python","defaults":{"NVIDIA/cuda-python":null}}', "Git default branch"), - ('{"repository":"NVIDIA/cuda-python","defaults":{"NVIDIA/cuda-python":"bad..branch"}}', "Git default branch"), - ( - '{"repository":"NVIDIA/cuda-python","defaults":{"NVIDIA/cuda-python":"bad\\u0000branch"}}', - "Git default branch", - ), - ], -) -@pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_invalid_ci_metadata_fails_explicitly(tmp_path, monkeypatch, raw, message): - monkeypatch.setenv(links.METADATA_ENV, raw) - with pytest.raises(ValueError, match=message): - links.repository_defaults(tmp_path) - - -@pytest.mark.parametrize("branch", ["next#toolkit", "next/\u03b2", "next%toolkit", "next$toolkit"]) -@pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_valid_branch_names_are_url_encoded_before_matching(tmp_path, monkeypatch, branch): - metadata = {"repository": links.PUBLIC_REPOSITORY, "defaults": {links.PUBLIC_REPOSITORY: branch}} - monkeypatch.setenv(links.METADATA_ENV, json.dumps(metadata)) - url = f"https://github.com/NVIDIA/cuda-python/blob/{quote(branch, safe='/')}/README.md#heading" - assert remap(links.repo_link_remaps(tmp_path), url) == f"{tmp_path.as_uri()}/README.md#heading" diff --git a/ci/tools/tests/test_checkout_source_links.py b/ci/tools/tests/test_checkout_source_links.py new file mode 100644 index 00000000000..170d94e78cb --- /dev/null +++ b/ci/tools/tests/test_checkout_source_links.py @@ -0,0 +1,164 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import checkout_source_links as links + + +def git(root, *arguments): + return subprocess.run( # noqa: S603 + ["git", "-C", str(root), *arguments], # noqa: S607 + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def checkout(tmp_path): + git(tmp_path, "init") + git( + tmp_path, + "-c", + "user.name=Link Test", + "-c", + "user.email=link-test@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "Initial test revision", + ) + return tmp_path + + +def remap(arguments, url): + for argument in arguments[1::2]: + pattern, replacement = argument.split() + url = re.sub(pattern, replacement.replace("$1", r"\g<1>"), url) + return url + + +@pytest.mark.parametrize("view", ["blob", "tree"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_authored_mapping_needs_no_git_or_repository_metadata(tmp_path, monkeypatch, view): + root = tmp_path / "checkout space$#" + monkeypatch.setenv("GITHUB_REPOSITORY", "invalid metadata") + with patch("checkout_source_links.subprocess.run") as run: + arguments = links.repo_link_remaps(root) + run.assert_not_called() + url = f"https://github.com/NVIDIA/cuda-python/{view}/main/docs/a%20guide.md?plain=1#heading" + assert remap(arguments, url) == f"{root.as_uri()}/docs/a%20guide.md?plain=1#heading" + + +@pytest.mark.parametrize("view", ["blob", "tree"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_public_rendered_mapping_uses_checkout_commit_and_keeps_suffix(tmp_path, monkeypatch, view): + root = checkout(tmp_path) + commit = git(root, "rev-parse", "HEAD") + monkeypatch.setenv("GITHUB_REPOSITORY", "nvidia/CUDA-PYTHON") + monkeypatch.setenv("GITHUB_BASE_REF", "12.9.x") + monkeypatch.setenv("GITHUB_REF_NAME", "123/merge") + arguments = links.repo_link_remaps(root, rendered=True) + url = f"https://github.com/NVIDIA/cuda-python/{view}/main/README.md?plain=1#heading" + assert remap(arguments, url) == f"https://github.com/NVIDIA/cuda-python/{view}/{commit}/README.md?plain=1#heading" + assert links.github_source_ref(root) == commit + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_other_rendered_checkout_omits_only_remapped_source_fragments(tmp_path, monkeypatch): + monkeypatch.setenv("GITHUB_REPOSITORY", "Example/project") + with patch("checkout_source_links.subprocess.run") as run: + arguments = links.repo_link_remaps(tmp_path, rendered=True) + assert links.github_source_ref(tmp_path) == "main" + run.assert_not_called() + for view in ("blob", "tree"): + url = f"https://github.com/NVIDIA/cuda-python/{view}/main/docs/guide.md?plain=1#heading" + assert remap(arguments, url) == f"{tmp_path.as_uri()}/docs/guide.md?plain=1" + for url in ("guide.html#heading", "file:///docs/guide.html#heading", "https://example.org/guide#heading"): + assert remap(arguments, url) == url + + +@pytest.mark.parametrize("rendered", [False, True]) +@pytest.mark.parametrize( + "url", + [ + "https://github.com/NVIDIA/cuda-python/blob/12.9.x/README.md#heading", + "https://github.com/NVIDIA/cuda-python/tree/v13.0.0/cuda_bindings", + "https://github.com/NVIDIA/cuda-python/blob/0123456789abcdef/README.md", + "https://github.com/NVIDIA/cuda-python/blob/mainish/README.md", + "https://github.com/NVIDIA/cuda-python/tree/main", + "https://github.com/Example/project/blob/main/README.md#heading", + "https://github.com/NVIDIA/other-repository/tree/main/docs", + "http://github.com/NVIDIA/cuda-python/blob/main/README.md", + "https://example.com/redirect?url=https://github.com/NVIDIA/cuda-python/blob/main/README.md", + ], +) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_unmatched_urls_remain_literal(tmp_path, monkeypatch, rendered, url): + monkeypatch.setenv("GITHUB_REPOSITORY", "Example/project") + assert remap(links.repo_link_remaps(tmp_path, rendered=rendered), url) == url + + +@pytest.mark.parametrize( + "url", + [ + "https://github.com/NVIDIA/cuda-python.git", + "git@github.com:NVIDIA/cuda-python.git", + "ssh://git@github.com/NVIDIA/cuda-python.git", + ], +) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_local_rendered_checkout_needs_no_cached_remote_head(tmp_path, monkeypatch, url): + monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) + root = checkout(tmp_path) + git(root, "remote", "add", "upstream", url) + git(root, "remote", "add", "origin", "https://github.com/Example/project.git") + assert links.github_source_ref(root) == git(root, "rev-parse", "HEAD") + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_explicit_local_repository_takes_precedence_over_remotes(tmp_path, monkeypatch): + monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) + root = checkout(tmp_path) + git(root, "config", "lychee.repository", links.PUBLIC_REPOSITORY) + git(root, "remote", "add", "upstream", "https://github.com/Example/project.git") + assert links.github_source_ref(root) == git(root, "rev-parse", "HEAD") + monkeypatch.setenv("GITHUB_REPOSITORY", "Example/project") + assert links.github_source_ref(root) == "main" + + +@pytest.mark.parametrize("identity", ["", "not-a-repository", "OWNER/", "https://github.com/Example/project"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_invalid_environment_identity_fails_explicitly(tmp_path, monkeypatch, identity): + monkeypatch.setenv("GITHUB_REPOSITORY", identity) + with pytest.raises(ValueError, match="OWNER/REPOSITORY"): + links.github_source_ref(tmp_path) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_invalid_explicit_local_identity_fails_instead_of_using_remotes(tmp_path, monkeypatch): + monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) + root = checkout(tmp_path) + git(root, "config", "lychee.repository", "invalid") + git(root, "remote", "add", "origin", "https://github.com/NVIDIA/cuda-python.git") + with pytest.raises(ValueError, match="OWNER/REPOSITORY"): + links.github_source_ref(root) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_unidentified_rendered_checkout_explains_explicit_configuration(tmp_path, monkeypatch): + monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) + root = checkout(tmp_path) + with pytest.raises(ValueError, match="git config lychee.repository OWNER/REPOSITORY"): + links.github_source_ref(root) From 74fc792b60d47d5885ced7bb212ab84dda82599c Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Mon, 19 Oct 2026 18:16:42 -0700 Subject: [PATCH 4/6] Slightly simplify ci/README.md --- ci/README.md | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/ci/README.md b/ci/README.md index 1a98d4e969e..ce20c30011c 100644 --- a/ci/README.md +++ b/ci/README.md @@ -128,14 +128,11 @@ prefix followed by `blob/main/` or `tree/main/`: documentation pages and their anchors still receive full validation. Tags, explicit commits, other branches, unrelated repositories, and URLs embedded -inside other URLs retain their original targets. There is no default-branch -lookup or remote `HEAD` requirement. A future change to the public URL convention -would require an explicit update to these matching rules. - -For rendered checks and checking builds, CI supplies the checkout identity -through `GITHUB_REPOSITORY`. Local runs use `git config lychee.repository`, or -otherwise the `upstream` remote followed by `origin`. A checkout whose remotes -only name a fork can select public-repository validation explicitly: +inside other URLs retain their original targets. For rendered checks and checking +builds, CI supplies the checkout identity through `GITHUB_REPOSITORY`. Local runs +use `git config lychee.repository`, or otherwise the `upstream` remote followed by +`origin`. A checkout whose remotes only name a fork can select public-repository +validation explicitly: ```bash git config lychee.repository NVIDIA/cuda-python From 9b28ebc014823c77ed4f2aa0151a01ee95b5e12c Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Mon, 19 Oct 2026 19:43:26 -0700 Subject: [PATCH 5/6] ci: verify source-link remap arguments reach Lychee Signed-off-by: Ralf W. Grosse-Kunstleve --- ci/tools/tests/test_run_lychee.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/ci/tools/tests/test_run_lychee.py b/ci/tools/tests/test_run_lychee.py index eee51dc0d87..2671b95a835 100644 --- a/ci/tools/tests/test_run_lychee.py +++ b/ci/tools/tests/test_run_lychee.py @@ -7,7 +7,7 @@ import subprocess import sys from pathlib import Path -from unittest.mock import patch +from unittest.mock import call, patch import pytest @@ -20,11 +20,12 @@ def test_native_reports_preserve_each_precommit_chunk_exit_code(tmp_path, monkeypatch): executable = tmp_path / "lychee" executable.touch() + remap_arguments = ["--remap", "source target"] monkeypatch.setenv("LYCHEE_REPORT_DIR", str(tmp_path / "reports")) with ( patch("run_lychee.sysconfig.get_path", return_value=str(tmp_path)), patch("run_lychee.sys.platform", "linux"), - patch("run_lychee.repo_link_remaps", return_value=[]), + patch("run_lychee.repo_link_remaps", return_value=remap_arguments) as remaps, patch( "run_lychee.subprocess.run", side_effect=[subprocess.CompletedProcess([], 2), subprocess.CompletedProcess([], 0)], @@ -32,11 +33,13 @@ def test_native_reports_preserve_each_precommit_chunk_exit_code(tmp_path, monkey ): assert main(["one.md"]) == 2 assert main(["two.rst"]) == 0 + assert remaps.call_args_list == [call(run_lychee.ROOT, rendered=False)] * 2 reports = [] - for call, code in zip(run.call_args_list, (2, 0), strict=True): - command = call.args[0] - assert command[1:4] == ["--format", "json", "--output"] - report = Path(command[4]) + for invocation, code in zip(run.call_args_list, (2, 0), strict=True): + command = invocation.args[0] + assert command[1:3] == remap_arguments + assert command[3:6] == ["--format", "json", "--output"] + report = Path(command[6]) assert report.parent == (tmp_path / "reports").resolve() assert json.loads(report.with_suffix(".status.json").read_text()) == {"exit_code": code} reports.append(report) @@ -53,19 +56,22 @@ def test_rendered_hook_checks_untracked_html_in_its_own_cache(tmp_path, monkeypa (docs / "_static").mkdir() (docs / "_static/theme.html").write_text("ignored theme") cache = tmp_path / ".lychee-rendered" + remap_arguments = ["--remap", "source target"] monkeypatch.setattr(run_lychee, "ROOT", tmp_path) monkeypatch.delenv("LYCHEE_REPORT_DIR", raising=False) with ( patch("run_lychee.sysconfig.get_path", return_value=str(tmp_path)), patch("run_lychee.sys.platform", "linux"), - patch("run_lychee.repo_link_remaps", return_value=[]), + patch("run_lychee.repo_link_remaps", return_value=remap_arguments) as remaps, patch("run_lychee.subprocess.run", return_value=subprocess.CompletedProcess([], 0)) as run, ): assert main(["--rendered", "--cache"]) == 0 + remaps.assert_called_once_with(tmp_path, rendered=True) assert (cache / "inputs.txt").read_text().splitlines() == [str(docs / "guide.html"), str(docs / "index.html")] run.assert_called_once_with( [ str(tmp_path / "lychee"), + *remap_arguments, "--files-from", str(cache / "inputs.txt"), "--include-fragments=full", @@ -85,11 +91,12 @@ def test_rendered_hook_rejects_missing_build_before_invoking_native_checker(tmp_ with ( patch("run_lychee.sysconfig.get_path", return_value=str(tmp_path)), patch("run_lychee.sys.platform", "linux"), - patch("run_lychee.repo_link_remaps", return_value=[]), + patch("run_lychee.repo_link_remaps") as remaps, patch("run_lychee.subprocess.run") as run, pytest.raises(ValueError, match="No documentation inputs"), ): main(["--rendered", "--cache"]) + remaps.assert_not_called() run.assert_not_called() From dd3100c23a6991756949142b7dc88c72ff8e84a9 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Mon, 19 Oct 2026 19:43:26 -0700 Subject: [PATCH 6/6] docs: explain local rendered checks for unpublished commits Signed-off-by: Ralf W. Grosse-Kunstleve --- ci/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ci/README.md b/ci/README.md index ce20c30011c..47448144ee0 100644 --- a/ci/README.md +++ b/ci/README.md @@ -138,6 +138,20 @@ validation explicitly: git config lychee.repository NVIDIA/cuda-python ``` +For rendered checks of an unpublished local commit, select your fork's identity +to validate source links against local files: + +```bash +git config lychee.repository YOUR_GITHUB_USER/cuda-python +``` + +Use your actual fork's owner and repository. Set this before both the checking +documentation build and the rendered hook; rebuild existing checking output if +its example links contain an unpublished SHA. Clear any explicit +`CUDA_PYTHON_DOCS_GITHUB_REF` override to use this selection. Only GitHub +source-view fragments are omitted; generated HTML anchors remain checked. +CI uses `GITHUB_REPOSITORY` and retains its normal validation policy. + The checking build uses the public checkout's actual commit for generated example links. In other repositories it uses `main`, allowing those public URLs to receive the same local target checks without inserting another repository's