Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/lychee-links/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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_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.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 63 additions & 3 deletions ci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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 <authored|rendered>`,
wrapped with the shared cache and report handling in
Expand Down Expand Up @@ -95,13 +97,71 @@ 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_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
their OS- and Python-specific caches.

### Repository Links

Documentation can link to a path added or moved in the same PR using its
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.

`ci/tools/checkout_source_links.py` matches the complete public repository
prefix followed by `blob/main/` or `tree/main/`:

- 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. 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
```

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
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 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

Pixi lockfile freshness enforcement and automated refreshes are temporarily
Expand Down
5 changes: 4 additions & 1 deletion ci/tools/build_docs_for_link_check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
104 changes: 104 additions & 0 deletions ci/tools/checkout_source_links.py
Original file line number Diff line number Diff line change
@@ -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()
6 changes: 5 additions & 1 deletion ci/tools/run_lychee.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import uuid
from pathlib import Path

from checkout_source_links import repo_link_remaps

ROOT = Path(__file__).resolve().parents[2]


Expand Down Expand Up @@ -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
Expand Down
46 changes: 42 additions & 4 deletions ci/tools/tests/test_build_docs_for_link_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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")

Expand All @@ -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",
}
Loading
Loading