From 3f6345c2cbbef63fbdffaf11411cc99da92d0b02 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Fri, 16 Oct 2026 08:45:51 -0700 Subject: [PATCH 1/9] ci: run the Lychee pre-commit hook through native Python wheels [skip ci] Install the pinned native Lychee wheel in the hook environment and resolve its executable from that environment directly. Document the same pre-commit invocation for PowerShell, cmd, and Git Bash, and extract focused launcher tests. Signed-off-by: Ralf W. Grosse-Kunstleve --- .pre-commit-config.yaml | 11 +++++-- CONTRIBUTING.md | 18 ++++------- ci/tools/run_lychee.py | 26 +++++++++++++++ ci/tools/tests/test_run_lychee.py | 54 +++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 ci/tools/run_lychee.py create mode 100644 ci/tools/tests/test_run_lychee.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2cc4bb91d26..b3d4f99ae24 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -105,12 +105,17 @@ repos: - Cython==3.2.9 # Link checking for authored documentation files - - repo: https://github.com/lycheeverse/lychee - rev: 2bba271688c1abb1503097a064e6c3bc1d1b6a9b # frozen: lychee-v0.24.2 + - repo: local hooks: - id: lychee + name: lychee + entry: python ./ci/tools/run_lychee.py + language: python + additional_dependencies: + - lychee-bin==0.24.2 # Must match check-doc-links/action.yml + types: [text] + require_serial: true args: - - LYCHEE_VERSION=0.24.2 # Must match the pinned rev above - --cache - --max-concurrency=4 - --max-retries=3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68e25ff0951..5481e39411a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ Thank you for your interest in contributing to CUDA Python! Based on the type of - [Symptoms of a bad clone](#symptoms-of-a-bad-clone) - [Development on Windows](#development-on-windows) - [Enabling git symlinks](#enabling-git-symlinks) - - [Pre-commit lychee workaround](#pre-commit-lychee-workaround) + - [Lychee on Windows](#lychee-on-windows) - [Type stubs for cuda.core](#type-stubs-for-cudacore) - [Pre-commit](#pre-commit) - [Pixi lockfiles](#pixi-lockfiles) @@ -192,17 +192,11 @@ $ reg.exe add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /v Then return to [Recommended clone](#recommended-clone). -### Pre-commit lychee workaround +### Lychee on Windows -For development on Windows (not WSL), the `lychee` pre-commit task will not -work when running `pre-commit run --all-files`. This problem does not occur -if you install the pre-commit hook and run it automatically as part of your -`git commit` workflow. To resolve this, you can either: - -1. Run `pre-commit` in Git Bash, rather than directly in PowerShell or cmd - -2. Skip it by setting the environment variable `SKIP` to `lychee`. This would - be `$env:SKIP = "lychee"` in PowerShell or `set SKIP=lychee` in cmd. +The `lychee` pre-commit hook runs through Python and installs the native +`lychee-bin==0.24.2` wheel in its own environment. Invoke +`pre-commit run --all-files` the same way from PowerShell, cmd, or Git Bash. ## Type stubs for cuda.core @@ -247,7 +241,7 @@ between commits, leaving stale headers or out-of-date stubs in the history. If the hook isn't installed, `pre-commit run` will print a visible warning reminding you to run `pre-commit install`. -Windows contributors: see [Pre-commit lychee workaround](#pre-commit-lychee-workaround) under Development on Windows. +Windows contributors: see [Lychee on Windows](#lychee-on-windows) under Development on Windows. ## Pixi lockfiles diff --git a/ci/tools/run_lychee.py b/ci/tools/run_lychee.py new file mode 100644 index 00000000000..b8cab4bf718 --- /dev/null +++ b/ci/tools/run_lychee.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Run the native Lychee installed in this Python environment.""" + +from __future__ import annotations + +import subprocess +import sys +import sysconfig +from pathlib import Path + + +def main(arguments: list[str] | None = None) -> int: + # Use this Python environment's installation, independent of PATH and the working directory. + executable = Path(sysconfig.get_path("scripts")) / ("lychee.exe" if sys.platform == "win32" else "lychee") + if not executable.is_file(): + raise SystemExit( + f"lychee was not found at {executable}; run this script through the configured pre-commit hook" + ) + arguments = sys.argv[1:] if arguments is None else arguments + return subprocess.run([str(executable), *arguments], check=False).returncode # noqa: S603 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/tools/tests/test_run_lychee.py b/ci/tools/tests/test_run_lychee.py new file mode 100644 index 00000000000..d46bc8cd8f5 --- /dev/null +++ b/ci/tools/tests/test_run_lychee.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from run_lychee import main + + +@pytest.mark.parametrize("platform", ["linux", "win32"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_main_uses_hook_environment_despite_other_binary_on_path(tmp_path, monkeypatch, platform): + scripts = tmp_path / "hook environment" / ("Scripts" if platform == "win32" else "bin") + scripts.mkdir(parents=True) + name = "lychee.exe" if platform == "win32" else "lychee" + executable = scripts / name + executable.touch() + other_directory = tmp_path / "other installation" + other_directory.mkdir() + (other_directory / name).touch(mode=0o755) + monkeypatch.setenv("PATH", str(other_directory)) + arguments = ["--cache", "--max-concurrency=4", "guide with spaces.md"] + with ( + patch("run_lychee.sysconfig.get_path", return_value=str(scripts)), + patch("run_lychee.sys.platform", platform), + patch("run_lychee.subprocess.run", return_value=subprocess.CompletedProcess([], 2)) as run, + ): + assert main(arguments) == 2 + run.assert_called_once_with([str(executable), *arguments], check=False) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_main_reports_missing_hook_binary_without_falling_back_to_path(tmp_path, monkeypatch): + scripts = tmp_path / "hook environment" / "bin" + other_directory = tmp_path / "other installation" + other_directory.mkdir() + (other_directory / "lychee").touch(mode=0o755) + monkeypatch.setenv("PATH", str(other_directory)) + with ( + patch("run_lychee.sysconfig.get_path", return_value=str(scripts)), + patch("run_lychee.sys.platform", "linux"), + patch("run_lychee.subprocess.run") as run, + pytest.raises(SystemExit, match="lychee was not found") as error, + ): + main(["guide.md"]) + assert str(scripts / "lychee") in str(error.value) + run.assert_not_called() From cb6b3418e3061b6e4fdc003da0a7a7e300924b46 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Fri, 16 Oct 2026 09:36:09 -0700 Subject: [PATCH 2/9] Enhance documentation in ci/tools/run_lychee.py Signed-off-by: Ralf W. Grosse-Kunstleve --- ci/tools/run_lychee.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ci/tools/run_lychee.py b/ci/tools/run_lychee.py index b8cab4bf718..0d8be24d06d 100644 --- a/ci/tools/run_lychee.py +++ b/ci/tools/run_lychee.py @@ -1,7 +1,15 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Run the native Lychee installed in this Python environment.""" +"""Run pre-commit's pinned native Lychee executable. + +The upstream Lychee hook uses a shell launcher, which requires a compatible +shell on Windows. We configure a Python hook so pre-commit installs the pinned +lychee-bin wheel in an isolated environment. This wrapper runs that +environment's native executable, passing through arguments and returning +its exit status. The same hook works on Linux and in Windows shells such as +PowerShell, cmd, and Git Bash. +""" from __future__ import annotations @@ -12,7 +20,7 @@ def main(arguments: list[str] | None = None) -> int: - # Use this Python environment's installation, independent of PATH and the working directory. + # Resolve the executable from this hook's environment to honor the pin. executable = Path(sysconfig.get_path("scripts")) / ("lychee.exe" if sys.platform == "win32" else "lychee") if not executable.is_file(): raise SystemExit( From f530e60d2c3d146d6ea6e825c078b64a5db7fe36 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Fri, 16 Oct 2026 10:07:33 -0700 Subject: [PATCH 3/9] ci: temporarily smoke-test the native Lychee hook offline Signed-off-by: Ralf W. Grosse-Kunstleve --- .github/workflows/pre-commit.yml | 7 +++ ci/tools/smoke_test_lychee_precommit.py | 62 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 ci/tools/smoke_test_lychee_precommit.py diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index fe4e07fbd11..cdfe6b48e5e 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -88,6 +88,13 @@ jobs: - name: Run pre-commit run: pre-commit run --all-files --show-diff-on-failure + # Keep this offline hook smoke check before network-dependent Lychee steps, + # so hook setup and invocation failures surface before link-check retries. + - name: Smoke-test the configured Lychee hook offline + env: + PYTHONUTF8: "1" + run: python ci/tools/smoke_test_lychee_precommit.py + - name: Save pre-commit cache # Only the default branch saves, so per-PR merge refs don't store their # own duplicate copies. !cancelled() keeps a push that fails lint from diff --git a/ci/tools/smoke_test_lychee_precommit.py b/ci/tools/smoke_test_lychee_precommit.py new file mode 100644 index 00000000000..7bbed4fdff5 --- /dev/null +++ b/ci/tools/smoke_test_lychee_precommit.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Offline smoke check for the configured native Lychee hook. + +Exercise the hook's environment, Python launcher, and native executable against +the README's local links without making external link requests. As an accepted +tradeoff for using a real document, a broken local README link also fails this +check even when the hook itself works. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +import yaml + +ROOT = Path(__file__).resolve().parents[2] + + +def main() -> int: + # Use the checked-in hook, including its entry point and pinned dependency. + # PyYAML is already installed as a dependency of pre-commit. + config = yaml.safe_load((ROOT / ".pre-commit-config.yaml").read_text(encoding="utf-8")) + [hook] = [hook for repo in config["repos"] for hook in repo["hooks"] if hook["id"] == "lychee"] + hook.setdefault("args", []).append("--offline") + + with TemporaryDirectory(prefix="lychee offline smoke ") as directory: + config_path = Path(directory) / "pre-commit.yaml" + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") + result = subprocess.run( # noqa: S603 + [ + sys.executable, + "-m", + "pre_commit", + "run", + "lychee", + "--config", + str(config_path), + "--files", + "README.md", + "--verbose", + ], + cwd=ROOT, + env={**os.environ, "SKIP": "", "PYTHONUTF8": "1"}, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + encoding="utf-8", + check=False, + ) + print(result.stdout, end="") + if result.returncode == 0 and ("- hook id: lychee" not in result.stdout or "Skipped" in result.stdout): + raise SystemExit("The configured Lychee hook did not run") + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) From 8101889403671c0f5d64af1b90cb62e84a0cf4be Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Fri, 16 Oct 2026 10:42:48 -0700 Subject: [PATCH 4/9] ci: rely on the offline smoke check for the native Lychee launcher Signed-off-by: Ralf W. Grosse-Kunstleve --- ci/tools/tests/test_run_lychee.py | 54 ------------------------------- 1 file changed, 54 deletions(-) delete mode 100644 ci/tools/tests/test_run_lychee.py diff --git a/ci/tools/tests/test_run_lychee.py b/ci/tools/tests/test_run_lychee.py deleted file mode 100644 index d46bc8cd8f5..00000000000 --- a/ci/tools/tests/test_run_lychee.py +++ /dev/null @@ -1,54 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -import subprocess -import sys -from pathlib import Path -from unittest.mock import patch - -import pytest - -sys.path.insert(0, str(Path(__file__).parent.parent)) -from run_lychee import main - - -@pytest.mark.parametrize("platform", ["linux", "win32"]) -@pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_main_uses_hook_environment_despite_other_binary_on_path(tmp_path, monkeypatch, platform): - scripts = tmp_path / "hook environment" / ("Scripts" if platform == "win32" else "bin") - scripts.mkdir(parents=True) - name = "lychee.exe" if platform == "win32" else "lychee" - executable = scripts / name - executable.touch() - other_directory = tmp_path / "other installation" - other_directory.mkdir() - (other_directory / name).touch(mode=0o755) - monkeypatch.setenv("PATH", str(other_directory)) - arguments = ["--cache", "--max-concurrency=4", "guide with spaces.md"] - with ( - patch("run_lychee.sysconfig.get_path", return_value=str(scripts)), - patch("run_lychee.sys.platform", platform), - patch("run_lychee.subprocess.run", return_value=subprocess.CompletedProcess([], 2)) as run, - ): - assert main(arguments) == 2 - run.assert_called_once_with([str(executable), *arguments], check=False) - - -@pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_main_reports_missing_hook_binary_without_falling_back_to_path(tmp_path, monkeypatch): - scripts = tmp_path / "hook environment" / "bin" - other_directory = tmp_path / "other installation" - other_directory.mkdir() - (other_directory / "lychee").touch(mode=0o755) - monkeypatch.setenv("PATH", str(other_directory)) - with ( - patch("run_lychee.sysconfig.get_path", return_value=str(scripts)), - patch("run_lychee.sys.platform", "linux"), - patch("run_lychee.subprocess.run") as run, - pytest.raises(SystemExit, match="lychee was not found") as error, - ): - main(["guide.md"]) - assert str(scripts / "lychee") in str(error.value) - run.assert_not_called() From 009b9f9d04450511afc9ac48dcf58939dbf89399 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Fri, 16 Oct 2026 10:43:28 -0700 Subject: [PATCH 5/9] docs: fold Windows shell guidance into pre-commit instructions Signed-off-by: Ralf W. Grosse-Kunstleve --- CONTRIBUTING.md | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5481e39411a..c4808f2cb02 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,6 @@ Thank you for your interest in contributing to CUDA Python! Based on the type of - [Symptoms of a bad clone](#symptoms-of-a-bad-clone) - [Development on Windows](#development-on-windows) - [Enabling git symlinks](#enabling-git-symlinks) - - [Lychee on Windows](#lychee-on-windows) - [Type stubs for cuda.core](#type-stubs-for-cudacore) - [Pre-commit](#pre-commit) - [Pixi lockfiles](#pixi-lockfiles) @@ -192,13 +191,6 @@ $ reg.exe add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /v Then return to [Recommended clone](#recommended-clone). -### Lychee on Windows - -The `lychee` pre-commit hook runs through Python and installs the native -`lychee-bin==0.24.2` wheel in its own environment. Invoke -`pre-commit run --all-files` the same way from PowerShell, cmd, or Git Bash. - - ## Type stubs for cuda.core `cuda.core` is a PEP 561-compliant package: it ships a `py.typed` marker and @@ -231,6 +223,8 @@ To set yourself up for running pre-commit checks locally and to catch issues bef * Run this once per checkout: `pre-commit install` * You can manually check all files at any time by running: `pre-commit run --all-files` +On Windows, these commands work from PowerShell, cmd, or Git Bash. + This command runs all configured hooks (such as linters and formatters) across your repository, letting you review and address issues before committing. Installing the hook is required, not optional. Some of the automated checks @@ -241,8 +235,6 @@ between commits, leaving stale headers or out-of-date stubs in the history. If the hook isn't installed, `pre-commit run` will print a visible warning reminding you to run `pre-commit install`. -Windows contributors: see [Lychee on Windows](#lychee-on-windows) under Development on Windows. - ## Pixi lockfiles The repository checks in a `pixi.lock` next to each `pixi.toml`. Those lockfiles From 6c61141412fb3072cd4770cd6c519a8825e1f06c Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Fri, 16 Oct 2026 11:00:08 -0700 Subject: [PATCH 6/9] ci: keep Lychee smoke configuration on the checkout drive Signed-off-by: Ralf W. Grosse-Kunstleve --- ci/tools/smoke_test_lychee_precommit.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ci/tools/smoke_test_lychee_precommit.py b/ci/tools/smoke_test_lychee_precommit.py index 7bbed4fdff5..6834f152fc1 100644 --- a/ci/tools/smoke_test_lychee_precommit.py +++ b/ci/tools/smoke_test_lychee_precommit.py @@ -29,7 +29,8 @@ def main() -> int: [hook] = [hook for repo in config["repos"] for hook in repo["hooks"] if hook["id"] == "lychee"] hook.setdefault("args", []).append("--offline") - with TemporaryDirectory(prefix="lychee offline smoke ") as directory: + # pre-commit resolves --config relative to the checkout; keep it on the same drive. + with TemporaryDirectory(prefix="lychee offline smoke ", dir=ROOT) as directory: config_path = Path(directory) / "pre-commit.yaml" config_path.write_text(yaml.safe_dump(config), encoding="utf-8") result = subprocess.run( # noqa: S603 From c4a6f696d9bfa930b5c90ef2099c59954a498676 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Fri, 16 Oct 2026 11:23:00 -0700 Subject: [PATCH 7/9] ci: smoke-test Lychee directly in Windows shells Signed-off-by: Ralf W. Grosse-Kunstleve --- .github/workflows/pre-commit.yml | 59 +++++++++++++++++++++++-- ci/tools/smoke_test_lychee_precommit.py | 44 +++++++++++++++--- 2 files changed, 92 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index cdfe6b48e5e..9c06e1ade64 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -85,16 +85,67 @@ jobs: - name: Install pre-commit run: python -m pip install --upgrade pip pre-commit - - name: Run pre-commit - run: pre-commit run --all-files --show-diff-on-failure - # Keep this offline hook smoke check before network-dependent Lychee steps, # so hook setup and invocation failures surface before link-check retries. - - name: Smoke-test the configured Lychee hook offline + - name: Smoke-test the configured Lychee hook offline (Linux) + if: runner.os == 'Linux' env: PYTHONUTF8: "1" run: python ci/tools/smoke_test_lychee_precommit.py + # Invoke pre-commit directly to cover each shell's command lookup and quoting. + # All three Windows shells reuse this offline configuration and hook environment. + - name: Prepare offline Lychee configuration (Windows) + if: runner.os == 'Windows' + id: lychee-smoke + run: | + directory="$(mktemp -d 'lychee offline smoke XXXXXX')" + echo "directory=$directory" >> "${GITHUB_OUTPUT}" + python ci/tools/smoke_test_lychee_precommit.py --prepare "$directory" + + - name: Smoke-test Lychee in Git Bash (Windows) + if: ${{ !cancelled() && runner.os == 'Windows' && steps.lychee-smoke.outcome == 'success' }} + shell: bash + env: + LYCHEE_SMOKE_DIR: ${{ steps.lychee-smoke.outputs.directory }} + SKIP: "" + PYTHONUTF8: "1" + run: | + status=0 + pre-commit run lychee --config "$LYCHEE_SMOKE_DIR/pre-commit.yaml" --files README.md --verbose > "$LYCHEE_SMOKE_DIR/bash.log" 2>&1 || status=$? + python ci/tools/smoke_test_lychee_precommit.py --verify "$LYCHEE_SMOKE_DIR/bash.log" "$status" + + - name: Smoke-test Lychee in PowerShell (Windows) + if: ${{ !cancelled() && runner.os == 'Windows' && steps.lychee-smoke.outcome == 'success' }} + shell: pwsh + env: + LYCHEE_SMOKE_DIR: ${{ steps.lychee-smoke.outputs.directory }} + SKIP: "" + PYTHONUTF8: "1" + run: | + pre-commit run lychee --config "$env:LYCHEE_SMOKE_DIR/pre-commit.yaml" --files README.md --verbose > "$env:LYCHEE_SMOKE_DIR/pwsh.log" 2>&1 + python ci/tools/smoke_test_lychee_precommit.py --verify "$env:LYCHEE_SMOKE_DIR/pwsh.log" $LASTEXITCODE + + - name: Smoke-test Lychee in cmd (Windows) + if: ${{ !cancelled() && runner.os == 'Windows' && steps.lychee-smoke.outcome == 'success' }} + shell: cmd + env: + LYCHEE_SMOKE_DIR: ${{ steps.lychee-smoke.outputs.directory }} + SKIP: "" + PYTHONUTF8: "1" + run: | + pre-commit run lychee --config "%LYCHEE_SMOKE_DIR%\pre-commit.yaml" --files README.md --verbose > "%LYCHEE_SMOKE_DIR%\cmd.log" 2>&1 + python ci/tools/smoke_test_lychee_precommit.py --verify "%LYCHEE_SMOKE_DIR%\cmd.log" %ERRORLEVEL% + + - name: Remove offline Lychee configuration (Windows) + if: ${{ always() && runner.os == 'Windows' && steps.lychee-smoke.outputs.directory != '' }} + env: + LYCHEE_SMOKE_DIR: ${{ steps.lychee-smoke.outputs.directory }} + run: python ci/tools/smoke_test_lychee_precommit.py --cleanup "$LYCHEE_SMOKE_DIR" + + - name: Run pre-commit + run: pre-commit run --all-files --show-diff-on-failure + - name: Save pre-commit cache # Only the default branch saves, so per-PR merge refs don't store their # own duplicate copies. !cancelled() keeps a push that fails lint from diff --git a/ci/tools/smoke_test_lychee_precommit.py b/ci/tools/smoke_test_lychee_precommit.py index 6834f152fc1..5464faeb7b5 100644 --- a/ci/tools/smoke_test_lychee_precommit.py +++ b/ci/tools/smoke_test_lychee_precommit.py @@ -11,7 +11,9 @@ from __future__ import annotations +import argparse import os +import shutil import subprocess import sys from pathlib import Path @@ -22,17 +24,48 @@ ROOT = Path(__file__).resolve().parents[2] -def main() -> int: +def write_config(directory: Path) -> Path: # Use the checked-in hook, including its entry point and pinned dependency. # PyYAML is already installed as a dependency of pre-commit. config = yaml.safe_load((ROOT / ".pre-commit-config.yaml").read_text(encoding="utf-8")) [hook] = [hook for repo in config["repos"] for hook in repo["hooks"] if hook["id"] == "lychee"] hook.setdefault("args", []).append("--offline") + config_path = directory / "pre-commit.yaml" + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") + return config_path + + +def verify(output: str, exit_code: int) -> int: + print(output, end="") + if exit_code == 0 and ("- hook id: lychee" not in output or "Skipped" in output): + raise SystemExit("The configured Lychee hook did not run") + return exit_code + + +def main() -> int: + # Windows CI uses separate phases so each shell invokes pre-commit directly. + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--prepare", type=Path, metavar="DIRECTORY") + mode.add_argument("--verify", nargs=2, metavar=("LOG", "EXIT_CODE")) + mode.add_argument("--cleanup", type=Path, metavar="DIRECTORY") + args = parser.parse_args() + if args.prepare is not None: + write_config(ROOT / args.prepare) + return 0 + if args.verify is not None: + log, exit_code = args.verify + return verify((ROOT / log).read_text(encoding="utf-8"), int(exit_code)) + if args.cleanup is not None: + directory = (ROOT / args.cleanup).resolve() + if directory.parent != ROOT or not directory.name.startswith("lychee offline smoke "): + raise SystemExit("Refusing to remove a directory outside the smoke check's temporary files") + shutil.rmtree(directory) + return 0 # pre-commit resolves --config relative to the checkout; keep it on the same drive. with TemporaryDirectory(prefix="lychee offline smoke ", dir=ROOT) as directory: - config_path = Path(directory) / "pre-commit.yaml" - config_path.write_text(yaml.safe_dump(config), encoding="utf-8") + config_path = write_config(Path(directory)) result = subprocess.run( # noqa: S603 [ sys.executable, @@ -53,10 +86,7 @@ def main() -> int: encoding="utf-8", check=False, ) - print(result.stdout, end="") - if result.returncode == 0 and ("- hook id: lychee" not in result.stdout or "Skipped" in result.stdout): - raise SystemExit("The configured Lychee hook did not run") - return result.returncode + return verify(result.stdout, result.returncode) if __name__ == "__main__": From 54e0c3e040577534e365dfff4942d9cd9ca01175 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Fri, 16 Oct 2026 11:52:14 -0700 Subject: [PATCH 8/9] ci: invoke Lychee directly from Bash on Linux Signed-off-by: Ralf W. Grosse-Kunstleve --- .github/workflows/pre-commit.yml | 20 ++++------- ci/tools/smoke_test_lychee_precommit.py | 47 +++++-------------------- 2 files changed, 15 insertions(+), 52 deletions(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 9c06e1ade64..322cc8e127d 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -87,24 +87,18 @@ jobs: # Keep this offline hook smoke check before network-dependent Lychee steps, # so hook setup and invocation failures surface before link-check retries. - - name: Smoke-test the configured Lychee hook offline (Linux) - if: runner.os == 'Linux' - env: - PYTHONUTF8: "1" - run: python ci/tools/smoke_test_lychee_precommit.py - # Invoke pre-commit directly to cover each shell's command lookup and quoting. - # All three Windows shells reuse this offline configuration and hook environment. - - name: Prepare offline Lychee configuration (Windows) - if: runner.os == 'Windows' + # Each runner's shells reuse one offline configuration and hook environment. + - name: Prepare offline Lychee configuration id: lychee-smoke + # pre-commit needs the config on the checkout's drive; spaces exercise quoting. run: | directory="$(mktemp -d 'lychee offline smoke XXXXXX')" echo "directory=$directory" >> "${GITHUB_OUTPUT}" python ci/tools/smoke_test_lychee_precommit.py --prepare "$directory" - - name: Smoke-test Lychee in Git Bash (Windows) - if: ${{ !cancelled() && runner.os == 'Windows' && steps.lychee-smoke.outcome == 'success' }} + - name: Smoke-test Lychee in ${{ runner.os == 'Windows' && 'Git Bash' || 'Bash' }} + if: ${{ !cancelled() && steps.lychee-smoke.outcome == 'success' }} shell: bash env: LYCHEE_SMOKE_DIR: ${{ steps.lychee-smoke.outputs.directory }} @@ -137,8 +131,8 @@ jobs: pre-commit run lychee --config "%LYCHEE_SMOKE_DIR%\pre-commit.yaml" --files README.md --verbose > "%LYCHEE_SMOKE_DIR%\cmd.log" 2>&1 python ci/tools/smoke_test_lychee_precommit.py --verify "%LYCHEE_SMOKE_DIR%\cmd.log" %ERRORLEVEL% - - name: Remove offline Lychee configuration (Windows) - if: ${{ always() && runner.os == 'Windows' && steps.lychee-smoke.outputs.directory != '' }} + - name: Remove offline Lychee configuration + if: ${{ always() && steps.lychee-smoke.outputs.directory != '' }} env: LYCHEE_SMOKE_DIR: ${{ steps.lychee-smoke.outputs.directory }} run: python ci/tools/smoke_test_lychee_precommit.py --cleanup "$LYCHEE_SMOKE_DIR" diff --git a/ci/tools/smoke_test_lychee_precommit.py b/ci/tools/smoke_test_lychee_precommit.py index 5464faeb7b5..d1b6e825d04 100644 --- a/ci/tools/smoke_test_lychee_precommit.py +++ b/ci/tools/smoke_test_lychee_precommit.py @@ -12,19 +12,15 @@ from __future__ import annotations import argparse -import os import shutil -import subprocess -import sys from pathlib import Path -from tempfile import TemporaryDirectory import yaml ROOT = Path(__file__).resolve().parents[2] -def write_config(directory: Path) -> Path: +def write_config(directory: Path) -> None: # Use the checked-in hook, including its entry point and pinned dependency. # PyYAML is already installed as a dependency of pre-commit. config = yaml.safe_load((ROOT / ".pre-commit-config.yaml").read_text(encoding="utf-8")) @@ -32,7 +28,6 @@ def write_config(directory: Path) -> Path: hook.setdefault("args", []).append("--offline") config_path = directory / "pre-commit.yaml" config_path.write_text(yaml.safe_dump(config), encoding="utf-8") - return config_path def verify(output: str, exit_code: int) -> int: @@ -43,9 +38,9 @@ def verify(output: str, exit_code: int) -> int: def main() -> int: - # Windows CI uses separate phases so each shell invokes pre-commit directly. + # CI uses separate phases so each shell invokes pre-commit directly. parser = argparse.ArgumentParser(description=__doc__) - mode = parser.add_mutually_exclusive_group() + mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--prepare", type=Path, metavar="DIRECTORY") mode.add_argument("--verify", nargs=2, metavar=("LOG", "EXIT_CODE")) mode.add_argument("--cleanup", type=Path, metavar="DIRECTORY") @@ -56,37 +51,11 @@ def main() -> int: if args.verify is not None: log, exit_code = args.verify return verify((ROOT / log).read_text(encoding="utf-8"), int(exit_code)) - if args.cleanup is not None: - directory = (ROOT / args.cleanup).resolve() - if directory.parent != ROOT or not directory.name.startswith("lychee offline smoke "): - raise SystemExit("Refusing to remove a directory outside the smoke check's temporary files") - shutil.rmtree(directory) - return 0 - - # pre-commit resolves --config relative to the checkout; keep it on the same drive. - with TemporaryDirectory(prefix="lychee offline smoke ", dir=ROOT) as directory: - config_path = write_config(Path(directory)) - result = subprocess.run( # noqa: S603 - [ - sys.executable, - "-m", - "pre_commit", - "run", - "lychee", - "--config", - str(config_path), - "--files", - "README.md", - "--verbose", - ], - cwd=ROOT, - env={**os.environ, "SKIP": "", "PYTHONUTF8": "1"}, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - encoding="utf-8", - check=False, - ) - return verify(result.stdout, result.returncode) + directory = (ROOT / args.cleanup).resolve() + if directory.parent != ROOT or not directory.name.startswith("lychee offline smoke "): + raise SystemExit("Refusing to remove a directory outside the smoke check's temporary files") + shutil.rmtree(directory) + return 0 if __name__ == "__main__": From 617834919cebcee568f5828bab5249774c780441 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Fri, 16 Oct 2026 14:40:32 -0700 Subject: [PATCH 9/9] ci: verify the Lychee smoke check using its JSON report Signed-off-by: Ralf W. Grosse-Kunstleve --- .github/actions/check-doc-links/action.yml | 2 +- ci/tools/smoke_test_lychee_precommit.py | 25 ++++++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/.github/actions/check-doc-links/action.yml b/.github/actions/check-doc-links/action.yml index 7bde3fbdd48..b835c744423 100644 --- a/.github/actions/check-doc-links/action.yml +++ b/.github/actions/check-doc-links/action.yml @@ -25,7 +25,7 @@ runs: shell: bash --noprofile --norc -euo pipefail {0} env: KIND: ${{ inputs.kind }} - LYCHEE_VERSION: v0.24.2 # Must match the pinned rev in .pre-commit-config.yaml + LYCHEE_VERSION: v0.24.2 # Must match lychee-bin in .pre-commit-config.yaml POLICY_HASH: ${{ hashFiles('lychee.toml', '.github/actions/check-doc-links/action.yml', 'ci/tools/prepare_lychee_inputs.py', 'ci/tools/retry_lychee.py') }} # The first pass and all retries use the same inputs and cache policy. LYCHEE_ARGS: >- diff --git a/ci/tools/smoke_test_lychee_precommit.py b/ci/tools/smoke_test_lychee_precommit.py index d1b6e825d04..db6db4113dc 100644 --- a/ci/tools/smoke_test_lychee_precommit.py +++ b/ci/tools/smoke_test_lychee_precommit.py @@ -16,8 +16,10 @@ from pathlib import Path import yaml +from retry_lychee import read_report ROOT = Path(__file__).resolve().parents[2] +REPORT_NAME = "lychee.json" def write_config(directory: Path) -> None: @@ -25,16 +27,25 @@ def write_config(directory: Path) -> None: # PyYAML is already installed as a dependency of pre-commit. config = yaml.safe_load((ROOT / ".pre-commit-config.yaml").read_text(encoding="utf-8")) [hook] = [hook for repo in config["repos"] for hook in repo["hooks"] if hook["id"] == "lychee"] - hook.setdefault("args", []).append("--offline") + report = directory / REPORT_NAME + hook.setdefault("args", []).extend(["--offline", "--format", "json", "--output", str(report)]) config_path = directory / "pre-commit.yaml" config_path.write_text(yaml.safe_dump(config), encoding="utf-8") -def verify(output: str, exit_code: int) -> int: - print(output, end="") - if exit_code == 0 and ("- hook id: lychee" not in output or "Skipped" in output): - raise SystemExit("The configured Lychee hook did not run") - return exit_code +def verify(log: Path, exit_code: int) -> int: + report = log.with_name(REPORT_NAME) + try: + print(log.read_text(encoding="utf-8"), end="") + if exit_code != 0: + return exit_code + if not report.is_file(): + raise SystemExit(f"The configured Lychee hook produced no report ({report}); was it skipped?") + read_report(report, exit_code) + return 0 + finally: + # Consume each shell's report so a later skipped hook cannot reuse it. + report.unlink(missing_ok=True) def main() -> int: @@ -50,7 +61,7 @@ def main() -> int: return 0 if args.verify is not None: log, exit_code = args.verify - return verify((ROOT / log).read_text(encoding="utf-8"), int(exit_code)) + return verify(ROOT / log, int(exit_code)) directory = (ROOT / args.cleanup).resolve() if directory.parent != ROOT or not directory.name.startswith("lychee offline smoke "): raise SystemExit("Refusing to remove a directory outside the smoke check's temporary files")