-
Notifications
You must be signed in to change notification settings - Fork 0
add check-project-links action #93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e3c6568
ab0adce
705a350
30bef4f
6116fb7
fd83fa7
44028ac
882ba28
c04646d
e3d97a1
35617bd
37f96fb
c7b1ff9
bd73f6a
8943bd4
b61454c
0e3d049
a772e33
1ef1d05
4c19544
d9ead73
d37c307
ad3ece3
bcbd82f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| # `ni/python-actions/check-project-links` | ||
|
|
||
| This action searches a project tree for `pyproject.toml` files, extracts `https://` links from those files, and validates each discovered URL by making an HTTP request in a Docker container. It fails only when a checked URL returns a `4xx` response. `2xx` and `3xx` responses are treated as successful and logged without failing the action. | ||
|
|
||
| ## Inputs | ||
|
|
||
| ### `project-directory` | ||
|
|
||
| Path to the directory containing one or more `pyproject.toml` files. | ||
|
|
||
| Default: `${{ github.workspace }}` | ||
|
|
||
| ### `allowed-domains` | ||
|
|
||
| Comma-separated list of trusted hostnames or domains to validate. Supports wildcards like `*.readthedocs.io`. | ||
|
|
||
| Default: `github.com,ni.github.io,*.readthedocs.io` | ||
|
|
||
| ### `docker-image` | ||
|
|
||
| Docker image used to perform the HTTP requests. | ||
|
|
||
| Default: `curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777` | ||
|
|
||
| > [!NOTE] | ||
| > The action default uses a full digest SHA, though this is not required. | ||
| ## Examples | ||
|
|
||
| > [!NOTE] | ||
| > These examples use `@v0`, but pinning to a commit hash or full release tag is recommended for | ||
| > build reproducibility and security. | ||
|
|
||
|
|
||
| ```yaml | ||
| steps: | ||
| - uses: actions/checkout@v0 | ||
|
|
||
| - name: Check project links | ||
| uses: ni/python-actions/check-project-links@v1 | ||
| with: | ||
| project-directory: . | ||
| docker-image: curlimages/curl:8.22.0 | ||
| ``` | ||
|
|
||
| ## Behavior | ||
|
|
||
| - Uses Python directory walk and [`tomllib`](https://docs.python.org/3/library/tomllib.html) + string search to identify all links in pyproject.toml files under the provided path. | ||
| - Extracts strings that are parsable URLs, and URLs from comments | ||
| - Drops any URL whose hostname is `localhost`, a local loopback address, or any literal IP address before validation. | ||
| - Deduplicates the list of URLs and writes them to a temporary file. | ||
| - Validates each URL with the configured Docker image. | ||
| - Fails immediately if `docker` is not installed or available on `PATH`. | ||
| - Logs `2xx` and `3xx` responses as passing. | ||
| - Fails the action only when a URL returns a `4xx` status code. | ||
| - Other status codes are considered a warning. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| #!/usr/bin/env python3 | ||
|
|
||
| import argparse | ||
| import ipaddress | ||
| import os | ||
| import sys | ||
| from urllib.parse import urlsplit | ||
|
|
||
| try: | ||
| import tomllib | ||
| except ModuleNotFoundError: # pragma: no cover | ||
| raise Exception("tomllib is not available. Please use Python 3.11 or later.") | ||
|
|
||
|
|
||
| def _parse_args() -> argparse.Namespace: | ||
| parser = argparse.ArgumentParser( | ||
| description="Collect trusted project URLs from pyproject.toml files." | ||
| ) | ||
| parser.add_argument( | ||
| "project_directory", help="Directory containing project pyproject.toml files." | ||
| ) | ||
| parser.add_argument("allowed_domains", help="Comma-separated list of allowed domains.") | ||
| parser.add_argument("output_path", help="Path to write discovered URLs.") | ||
| return parser.parse_args() | ||
|
|
||
|
|
||
| def _is_allowed(hostname: str, allowed: set[str]) -> bool: | ||
| """Check if the given hostname is allowed based on the provided set of allowed domains. | ||
|
|
||
| >>> _is_allowed('example.com', {'example.com'}) | ||
| True | ||
|
|
||
| >>> _is_allowed('sub.example.com', {'example.com'}) | ||
| False | ||
|
|
||
| >>> _is_allowed('sub.example.com', {'*.example.com'}) | ||
| True | ||
| """ | ||
| if not hostname: | ||
| return False | ||
|
|
||
| host = hostname.lower().rstrip(".") | ||
| if host == "localhost" or host.endswith(".localhost"): | ||
| return False | ||
|
|
||
| try: | ||
| ip = ipaddress.ip_address(host) | ||
| except ValueError: | ||
| ip = None | ||
|
|
||
| if ip is not None: | ||
| return False | ||
|
|
||
| if host in allowed: | ||
| return True | ||
|
|
||
| if any( | ||
| host.endswith(f'.{domain.lstrip(".*")}') for domain in allowed if domain.startswith("*.") | ||
| ): | ||
| return True | ||
|
|
||
| return False | ||
|
|
||
|
|
||
| def _safe_under(base_dir: str, candidate_path: str) -> str: | ||
| """Return a canonical path that stays under base_dir, or raise ValueError.""" | ||
| safe_base = os.path.realpath(base_dir) | ||
| safe_candidate = os.path.realpath(candidate_path) | ||
|
|
||
| try: | ||
| if os.path.commonpath([safe_base, safe_candidate]) != safe_base: | ||
| raise ValueError(f"Path escapes base directory: {candidate_path!r}") | ||
| except ValueError as exc: | ||
| raise ValueError(f"Path escapes base directory: {candidate_path!r}") from exc | ||
|
|
||
| return safe_candidate | ||
|
|
||
|
|
||
| def _walk_metadata(value, results: set[str], allowed: set[str]) -> None: | ||
| if isinstance(value, dict): | ||
| for item in value.values(): | ||
| _walk_metadata(item, results, allowed) | ||
| elif isinstance(value, list): | ||
| for item in value: | ||
| _walk_metadata(item, results, allowed) | ||
| elif isinstance(value, str): | ||
| if not value.startswith("https://"): | ||
| return | ||
| host = urlsplit(value).hostname | ||
| if host and _is_allowed(host, allowed): | ||
| results.add(value) | ||
|
|
||
|
|
||
| def _main() -> int: | ||
| args = _parse_args() | ||
| project_directory = args.project_directory | ||
| safe_project_directory = os.path.realpath(project_directory, strict=True) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No pathlib? Where is @mshafer-NI and what have you done with him? 😅 |
||
| safe_project_directory = _safe_under(os.getcwd(), safe_project_directory) | ||
| allowed_domains = args.allowed_domains | ||
| safe_output_path = _safe_under("/tmp", os.path.realpath(args.output_path, strict=True)) | ||
|
Comment on lines
+96
to
+100
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I get that you want to avoid following untrusted symlinks, but I think the action should be able to trust that I also think that requiring the output path to be under Checking that the pyproject.toml files are under the project directory seems reasonable, though. |
||
|
|
||
| allowed: set[str] = set() | ||
| for raw_domain in allowed_domains.split(","): | ||
| domain = raw_domain.strip().lower().rstrip(".") | ||
| if domain: | ||
| allowed.add(domain) | ||
|
|
||
| results: set[str] = set() | ||
|
|
||
| for root, _, files in os.walk(safe_project_directory): | ||
| for file_name in files: | ||
| if file_name != "pyproject.toml": | ||
| continue | ||
|
|
||
| manifest_path = _safe_under(safe_project_directory, os.path.join(root, file_name)) | ||
| try: | ||
| with open(manifest_path, "rb") as manifest_file: | ||
| metadata = tomllib.load(manifest_file) | ||
| except (OSError, tomllib.TOMLDecodeError): | ||
| print( | ||
| f"Warning: Failed to read or parse pyproject.toml at {manifest_path}", | ||
| file=sys.stderr, | ||
| ) | ||
| continue | ||
|
|
||
| _walk_metadata(metadata, results, allowed) | ||
| # Also find any commented URLs in the pyproject.toml file | ||
| try: | ||
| with open(manifest_path, "r", encoding="utf-8") as manifest_file: | ||
| for line in manifest_file: | ||
| line = line.strip() | ||
| prefix, comment = line.split("#", maxsplit=1) if "#" in line else (line, "") | ||
| if comment.strip(): | ||
| comment = comment.strip() | ||
| if comment.startswith("https://"): | ||
| host = urlsplit( | ||
| comment | ||
| ).hostname # handles extra at the end just fine | ||
| if host and _is_allowed(host, allowed): | ||
| results.add(comment) | ||
|
Comment on lines
+129
to
+140
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: extract a function |
||
| except OSError: | ||
| print(f"Warning: Failed to read pyproject.toml at {manifest_path}", file=sys.stderr) | ||
| continue | ||
|
|
||
| output_directory = os.path.dirname(safe_output_path) | ||
| if output_directory: | ||
| os.makedirs(output_directory, exist_ok=True) | ||
|
|
||
| with open(safe_output_path, "w", encoding="utf-8") as output_file: | ||
| for url in sorted(results): | ||
| output_file.write(f"{url}\n") | ||
|
|
||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(_main()) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the required order for adding readthedocs support to a project?
Do you need to publish a (pre-)release? I guess not.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not exactly sure what this looks like... I guess if we say all links must be valid at CI time..., then it would be: (or similar)..., which is admittedly not great... Another option:
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's probably fine as long as you can bring up the RTD site before publishing the package to PyPI. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,94 @@ | ||||||||||||||||
| name: Check project links | ||||||||||||||||
| description: Find URLs referenced in pyproject.toml files and fail only when a response is 4xx. | ||||||||||||||||
|
|
||||||||||||||||
| inputs: | ||||||||||||||||
| project-directory: | ||||||||||||||||
| description: Path to the directory containing pyproject.toml files. | ||||||||||||||||
| default: ${{ github.workspace }} | ||||||||||||||||
| allowed-domains: | ||||||||||||||||
| description: Comma-separated list of trusted hostnames or domains to validate. Supports wildcards like *.readthedocs.io. | ||||||||||||||||
| default: github.com,ni.github.io,*.readthedocs.io | ||||||||||||||||
| docker-image: | ||||||||||||||||
| description: Docker image used to validate each discovered URL. | ||||||||||||||||
| default: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777 | ||||||||||||||||
|
|
||||||||||||||||
| runs: | ||||||||||||||||
| using: composite | ||||||||||||||||
| steps: | ||||||||||||||||
| - name: Check project links | ||||||||||||||||
| id: check-project-links | ||||||||||||||||
| shell: bash | ||||||||||||||||
| env: | ||||||||||||||||
| PROJECT_DIRECTORY: ${{ inputs.project-directory }} | ||||||||||||||||
| ALLOWED_DOMAINS: ${{ inputs.allowed-domains }} | ||||||||||||||||
| DOCKER_IMAGE: ${{ inputs.docker-image }} | ||||||||||||||||
| run: | | ||||||||||||||||
| set -euo pipefail | ||||||||||||||||
|
|
||||||||||||||||
| if [ ! -d "$PROJECT_DIRECTORY" ]; then | ||||||||||||||||
| echo "::error title=Check Project Links Error::Project directory '$PROJECT_DIRECTORY' does not exist." | ||||||||||||||||
| exit 1 | ||||||||||||||||
| fi | ||||||||||||||||
|
|
||||||||||||||||
| if ! command -v docker >/dev/null 2>&1; then | ||||||||||||||||
| echo "::error title=Check Project Links Error::docker is not available. Install Docker or add a pre-step that installs it before using this action." | ||||||||||||||||
| exit 1 | ||||||||||||||||
| fi | ||||||||||||||||
|
|
||||||||||||||||
| link_file="$(mktemp)" | ||||||||||||||||
| cleanup() { | ||||||||||||||||
| rm -f "$link_file" | ||||||||||||||||
| } | ||||||||||||||||
|
mshafer-NI marked this conversation as resolved.
|
||||||||||||||||
| trap cleanup EXIT | ||||||||||||||||
| chmod 0644 "$link_file" | ||||||||||||||||
|
Comment on lines
+38
to
+43
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. runner.temp points to a temp directory that is cleared after each job. I think it should already be in the environment as I would expect the runner to have a reasonable umask like 002 or 022 (see https://github.com/orgs/community/discussions/40876 ), so chmod should not be necessary.
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| python3 "$GITHUB_ACTION_PATH/_find_project_links.py" "$PROJECT_DIRECTORY" "$ALLOWED_DOMAINS" "$link_file" | ||||||||||||||||
| chmod 0644 "$link_file" | ||||||||||||||||
|
|
||||||||||||||||
| if [ ! -s "$link_file" ]; then | ||||||||||||||||
| echo "No trusted project links found under $PROJECT_DIRECTORY." | ||||||||||||||||
| exit 0 | ||||||||||||||||
| fi | ||||||||||||||||
|
|
||||||||||||||||
| echo "Found $(wc -l < "$link_file") unique trusted links:" | ||||||||||||||||
| cat "$link_file" | ||||||||||||||||
|
|
||||||||||||||||
| if [ ! -s "$link_file" ]; then | ||||||||||||||||
| echo "No trusted project links found." | ||||||||||||||||
| exit 0 | ||||||||||||||||
| fi | ||||||||||||||||
|
|
||||||||||||||||
| echo "Found $(wc -l < "$link_file") unique trusted links:" | ||||||||||||||||
| cat "$link_file" | ||||||||||||||||
|
Comment on lines
+48
to
+62
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is duplicated. |
||||||||||||||||
|
|
||||||||||||||||
| unprivileged_user=100:100 # this matches the default, but we want to be explicit about it | ||||||||||||||||
| docker run -u "${unprivileged_user}" --rm \ | ||||||||||||||||
| -v "$link_file:/tmp/project_links.txt:ro" \ | ||||||||||||||||
| "$DOCKER_IMAGE" \ | ||||||||||||||||
| sh -ec ' | ||||||||||||||||
| failed=0 | ||||||||||||||||
| while IFS= read -r url; do | ||||||||||||||||
| [ -n "$url" ] || continue | ||||||||||||||||
| status=$(curl -L -sS --connect-timeout 5 --max-time 20 -o /tmp/link_body -w "%{http_code}" "$url" || true) | ||||||||||||||||
| case "$status" in | ||||||||||||||||
| 2??|3??) | ||||||||||||||||
| echo "PASS $url -> $status" | ||||||||||||||||
| ;; | ||||||||||||||||
| 4??) | ||||||||||||||||
| echo "FAIL $url -> $status" | ||||||||||||||||
| failed=1 | ||||||||||||||||
| ;; | ||||||||||||||||
| *) | ||||||||||||||||
| echo "WARN $url -> $status" | ||||||||||||||||
| ;; | ||||||||||||||||
| esac | ||||||||||||||||
| done < /tmp/project_links.txt | ||||||||||||||||
| exit "$failed" | ||||||||||||||||
| ' | ||||||||||||||||
|
|
||||||||||||||||
| status=$? | ||||||||||||||||
| if [ "$status" -ne 0 ]; then | ||||||||||||||||
| exit 1 | ||||||||||||||||
| fi | ||||||||||||||||
|
|
||||||||||||||||
| echo "Success: No checked project links returned 4xx responses." | ||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.