-
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
Merged
mshafer-NI
merged 38 commits into
main
from
users/mshafer-ni/add_check_project_links_action
Sep 4, 2026
Merged
Changes from all commits
Commits
Show all changes
38 commits
Select commit
Hold shift + click to select a range
e3c6568
copilot: first draft
mshafer-NI ab0adce
copilot: setup tests
mshafer-NI 705a350
fix zizmor issues
mshafer-NI 30bef4f
use @v0 in doc and add disclaimer
mshafer-NI 6116fb7
don't care about the full link for http warning
mshafer-NI fd83fa7
also handle quote ended links
mshafer-NI 44028ac
fix copilot suggested issues
mshafer-NI 882ba28
add trusted domains input and limit to only checking https links at t…
mshafer-NI c04646d
add checks for rg and docker commands
mshafer-NI e3d97a1
whitespace
mshafer-NI 35617bd
Apply batched suggestions from code review
mshafer-NI 37f96fb
Merge branch 'users/mshafer-ni/add_check_project_links_action' of htt…
mshafer-NI c7b1ff9
explicitly drop loopback and IP based hosts
mshafer-NI bd73f6a
Apply batched suggestions from code review
mshafer-NI 8943bd4
use existing env vars directly
mshafer-NI b61454c
move it to a py file
mshafer-NI 0e3d049
Merge branch 'users/mshafer-ni/add_check_project_links_action' of htt…
mshafer-NI a772e33
update doc
mshafer-NI 1ef1d05
switch to python impl
mshafer-NI 4c19544
all private
mshafer-NI d9ead73
fix docstring
mshafer-NI d37c307
format
mshafer-NI ad3ece3
also not a public module
mshafer-NI bcbd82f
set the file as world read so the docker user can read it
mshafer-NI 970e7ad
extract comment searching to method
mshafer-NI 97dbe01
remove duplicate check
mshafer-NI 9ecc8aa
use RUNNER_TEMP
mshafer-NI ee8f981
use RUNNER_TEMP and test not changing mode
mshafer-NI 2da41f9
turns out chmod is required because mktemp makes it too restricted
mshafer-NI d69e232
don't require output to be in temp, just resolve it
mshafer-NI a24c0d8
add type annotation
mshafer-NI e7461a4
just say any
mshafer-NI c77494f
let mypy pass on 3.10
mshafer-NI ced8a58
switch to just regex parsing
mshafer-NI 99c7d0b
add comment
mshafer-NI e410a1c
Apply batched suggestions from code review
mshafer-NI 4b805eb
Simplify exit handling for project link checks
mshafer-NI 92c4c6e
tone down the sanitizing
mshafer-NI File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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@v0 | ||
| with: | ||
| project-directory: . | ||
| docker-image: curlimages/curl:8.22.0 | ||
| ``` | ||
|
|
||
| ## Behavior | ||
|
|
||
| - Uses Python directory walk + regex search to identify `https://` links in `pyproject.toml` files under the provided path. | ||
| - Extracts `https://` substrings (including from comments) and filters them by the configured allowed domains before validation. | ||
| - 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| #!/usr/bin/env python3 | ||
|
|
||
| import argparse | ||
| import ipaddress | ||
| import os | ||
| import re | ||
| import sys | ||
| from urllib.parse import urlsplit | ||
|
|
||
|
|
||
| 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 | ||
|
|
||
|
|
||
| _URL_RE = re.compile(r'(https://.+?)(?:"|\s)') | ||
|
|
||
|
|
||
| def _extract_links_from_line(line: str) -> list[str]: | ||
| """Extract URLs from a single line, stopping before a quote or whitespace.""" | ||
| matches: list[str] = [] | ||
| for match in re.finditer(_URL_RE, line): | ||
| url = match.group(1) | ||
| if url: | ||
| matches.append(url) | ||
| return matches | ||
|
|
||
|
|
||
| def _find_project_links(manifest_path: str, results: set[str], allowed: set[str]) -> None: | ||
| """Find URLs within a pyproject.toml file without parsing TOML.""" | ||
| try: | ||
| with open(manifest_path, "r", encoding="utf-8") as manifest_file: | ||
| for line in manifest_file: | ||
| for url in _extract_links_from_line(line): | ||
| host = urlsplit(url).hostname | ||
| if host and _is_allowed(host, allowed): | ||
| results.add(url) | ||
| except OSError: | ||
| print(f"Warning: Failed to read pyproject.toml at {manifest_path}", file=sys.stderr) | ||
|
|
||
|
|
||
| def _main() -> int: | ||
| args = _parse_args() | ||
| safe_project_directory = _safe_under(os.getcwd(), args.project_directory) | ||
| allowed_domains = args.allowed_domains | ||
| output_path = args.output_path | ||
|
|
||
| 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)) | ||
| _find_project_links(manifest_path, results, allowed) | ||
|
|
||
| output_directory = os.path.dirname(output_path) | ||
| if output_directory: | ||
| os.makedirs(output_directory, exist_ok=True) | ||
|
|
||
| with open(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()) |
|
mshafer-NI marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| 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 -p "$RUNNER_TEMP")" | ||
| cleanup() { | ||
| rm -f "$link_file" | ||
| } | ||
|
mshafer-NI marked this conversation as resolved.
|
||
| trap cleanup EXIT | ||
|
|
||
| chmod 644 "$link_file" # make the file world-readable so that the unprivileged user in the docker container can read it | ||
|
|
||
| python3 "$GITHUB_ACTION_PATH/_find_project_links.py" "$PROJECT_DIRECTORY" "$ALLOWED_DOMAINS" "$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" | ||
|
|
||
| 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" | ||
| ' | ||
|
|
||
| # `docker run` failures already cause this step to fail due to `set -e`. | ||
|
|
||
| echo "Success: No checked project links returned 4xx responses." | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.