Skip to content

Commit 8909330

Browse files
mshafer-NICopilot
andauthored
add check-project-links action (#93)
* copilot: first draft * copilot: setup tests * fix zizmor issues * use @v0 in doc and add disclaimer * don't care about the full link for http warning * also handle quote ended links * fix copilot suggested issues * add trusted domains input and limit to only checking https links at those domains * add checks for rg and docker commands * whitespace * Apply batched suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * explicitly drop loopback and IP based hosts * Apply batched suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * use existing env vars directly * move it to a py file * update doc * switch to python impl * all private * fix docstring * format * also not a public module * set the file as world read so the docker user can read it * extract comment searching to method * remove duplicate check * use RUNNER_TEMP * use RUNNER_TEMP and test not changing mode * turns out chmod is required because mktemp makes it too restricted * don't require output to be in temp, just resolve it * add type annotation * just say any * let mypy pass on 3.10 * switch to just regex parsing * add comment * Apply batched suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Simplify exit handling for project link checks Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * tone down the sanitizing --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent e8a0145 commit 8909330

5 files changed

Lines changed: 334 additions & 0 deletions

File tree

.github/workflows/test_actions.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,63 @@ jobs:
254254
project-directory: test-project
255255
expected-version: 1.0.2.dev1
256256

257+
test_check_project_links:
258+
name: Test check-project-links
259+
runs-on: ubuntu-latest
260+
steps:
261+
- name: Check out repo
262+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
263+
with:
264+
persist-credentials: false
265+
- name: Create project with a valid link
266+
run: |
267+
mkdir -p test-project
268+
cat > test-project/pyproject.toml <<'EOF'
269+
[project]
270+
name = "test-project"
271+
version = "0.1.0"
272+
description = "Test project with a valid URL"
273+
authors = [{ name = "NI" }]
274+
readme = "README.md"
275+
requires-python = ">=3.9"
276+
277+
[project.urls]
278+
Homepage = "https://github.com/ni/python-actions"
279+
Documentation = "https://github.com/ni/python-actions/blob/main/README.md"
280+
EOF
281+
shell: bash
282+
- name: Check valid project links
283+
uses: ./check-project-links
284+
with:
285+
project-directory: test-project
286+
- name: Create project with a missing link
287+
run: |
288+
mkdir -p failing-project
289+
cat > failing-project/pyproject.toml <<'EOF'
290+
[project]
291+
name = "failing-project"
292+
version = "0.1.0"
293+
description = "Test project with a 404 URL"
294+
authors = [{ name = "NI" }]
295+
readme = "README.md"
296+
requires-python = ">=3.9"
297+
298+
[project.urls]
299+
Broken = "https://github.com/ni/project-that-does-not-exist"
300+
EOF
301+
shell: bash
302+
- name: Check missing project link (expected to fail)
303+
id: expected-failure
304+
continue-on-error: true
305+
uses: ./check-project-links
306+
with:
307+
project-directory: failing-project
308+
- name: Error if the previous step didn't fail
309+
if: steps.expected-failure.outcome != 'failure'
310+
run: |
311+
echo "::error title=Test Failure::The previous step did not fail as expected."
312+
exit 1
313+
257314
test_analyze_project:
258315
name: Test analyze-project
259316
runs-on: ${{ matrix.os }}
@@ -454,6 +511,7 @@ jobs:
454511
test_setup_poetry_no_cache,
455512
test_check_project_version,
456513
test_update_project_version,
514+
test_check_project_links,
457515
test_analyze_project,
458516
test_analyze_project_repo_root,
459517
]

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
- [`ni/python-actions/check-project-version`](check-project-version): uses Poetry to get the version
1414
of a Python project and checks that it matches an expected version. Publish workflows can use this
1515
to verify that the release tag matches the version number in `pyproject.toml`.
16+
- [`ni/python-actions/check-project-links`](check-project-links): scans project `pyproject.toml` files
17+
for URLs, writes the discovered links to a temporary file, and validates each URL in Docker while
18+
failing only on `4xx` responses.
1619
- [`ni/python-actions/update-project-version`](update-project-version): uses Poetry to update the
1720
version of a Python project and creates a pull request to modify its `pyproject.toml` file.
1821
Publish workflows can use this to update the version in `pyproject.toml` for the next build.

check-project-links/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# `ni/python-actions/check-project-links`
2+
3+
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.
4+
5+
## Inputs
6+
7+
### `project-directory`
8+
9+
Path to the directory containing one or more `pyproject.toml` files.
10+
11+
Default: `${{ github.workspace }}`
12+
13+
### `allowed-domains`
14+
15+
Comma-separated list of trusted hostnames or domains to validate. Supports wildcards like `*.readthedocs.io`.
16+
17+
Default: `github.com,ni.github.io,*.readthedocs.io`
18+
19+
### `docker-image`
20+
21+
Docker image used to perform the HTTP requests.
22+
23+
Default: `curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777`
24+
25+
> [!NOTE]
26+
> The action default uses a full digest SHA, though this is not required.
27+
## Examples
28+
29+
> [!NOTE]
30+
> These examples use `@v0`, but pinning to a commit hash or full release tag is recommended for
31+
> build reproducibility and security.
32+
33+
34+
```yaml
35+
steps:
36+
- uses: actions/checkout@v0
37+
38+
- name: Check project links
39+
uses: ni/python-actions/check-project-links@v0
40+
with:
41+
project-directory: .
42+
docker-image: curlimages/curl:8.22.0
43+
```
44+
45+
## Behavior
46+
47+
- Uses Python directory walk + regex search to identify `https://` links in `pyproject.toml` files under the provided path.
48+
- Extracts `https://` substrings (including from comments) and filters them by the configured allowed domains before validation.
49+
- Drops any URL whose hostname is `localhost`, a local loopback address, or any literal IP address before validation.
50+
- Deduplicates the list of URLs and writes them to a temporary file.
51+
- Validates each URL with the configured Docker image.
52+
- Fails immediately if `docker` is not installed or available on `PATH`.
53+
- Logs `2xx` and `3xx` responses as passing.
54+
- Fails the action only when a URL returns a `4xx` status code.
55+
- Other status codes are considered a warning.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import ipaddress
5+
import os
6+
import re
7+
import sys
8+
from urllib.parse import urlsplit
9+
10+
11+
def _parse_args() -> argparse.Namespace:
12+
parser = argparse.ArgumentParser(
13+
description="Collect trusted project URLs from pyproject.toml files."
14+
)
15+
parser.add_argument(
16+
"project_directory", help="Directory containing project pyproject.toml files."
17+
)
18+
parser.add_argument("allowed_domains", help="Comma-separated list of allowed domains.")
19+
parser.add_argument("output_path", help="Path to write discovered URLs.")
20+
return parser.parse_args()
21+
22+
23+
def _is_allowed(hostname: str, allowed: set[str]) -> bool:
24+
"""Check if the given hostname is allowed based on the provided set of allowed domains.
25+
26+
>>> _is_allowed('example.com', {'example.com'})
27+
True
28+
29+
>>> _is_allowed('sub.example.com', {'example.com'})
30+
False
31+
32+
>>> _is_allowed('sub.example.com', {'*.example.com'})
33+
True
34+
"""
35+
if not hostname:
36+
return False
37+
38+
host = hostname.lower().rstrip(".")
39+
if host == "localhost" or host.endswith(".localhost"):
40+
return False
41+
42+
try:
43+
ip = ipaddress.ip_address(host)
44+
except ValueError:
45+
ip = None
46+
47+
if ip is not None:
48+
return False
49+
50+
if host in allowed:
51+
return True
52+
53+
if any(
54+
host.endswith(f'.{domain.lstrip(".*")}') for domain in allowed if domain.startswith("*.")
55+
):
56+
return True
57+
58+
return False
59+
60+
61+
def _safe_under(base_dir: str, candidate_path: str) -> str:
62+
"""Return a canonical path that stays under base_dir, or raise ValueError."""
63+
safe_base = os.path.realpath(base_dir)
64+
safe_candidate = os.path.realpath(candidate_path)
65+
66+
try:
67+
if os.path.commonpath([safe_base, safe_candidate]) != safe_base:
68+
raise ValueError(f"Path escapes base directory: {candidate_path!r}")
69+
except ValueError as exc:
70+
raise ValueError(f"Path escapes base directory: {candidate_path!r}") from exc
71+
72+
return safe_candidate
73+
74+
75+
_URL_RE = re.compile(r'(https://.+?)(?:"|\s)')
76+
77+
78+
def _extract_links_from_line(line: str) -> list[str]:
79+
"""Extract URLs from a single line, stopping before a quote or whitespace."""
80+
matches: list[str] = []
81+
for match in re.finditer(_URL_RE, line):
82+
url = match.group(1)
83+
if url:
84+
matches.append(url)
85+
return matches
86+
87+
88+
def _find_project_links(manifest_path: str, results: set[str], allowed: set[str]) -> None:
89+
"""Find URLs within a pyproject.toml file without parsing TOML."""
90+
try:
91+
with open(manifest_path, "r", encoding="utf-8") as manifest_file:
92+
for line in manifest_file:
93+
for url in _extract_links_from_line(line):
94+
host = urlsplit(url).hostname
95+
if host and _is_allowed(host, allowed):
96+
results.add(url)
97+
except OSError:
98+
print(f"Warning: Failed to read pyproject.toml at {manifest_path}", file=sys.stderr)
99+
100+
101+
def _main() -> int:
102+
args = _parse_args()
103+
safe_project_directory = _safe_under(os.getcwd(), args.project_directory)
104+
allowed_domains = args.allowed_domains
105+
output_path = args.output_path
106+
107+
allowed: set[str] = set()
108+
for raw_domain in allowed_domains.split(","):
109+
domain = raw_domain.strip().lower().rstrip(".")
110+
if domain:
111+
allowed.add(domain)
112+
113+
results: set[str] = set()
114+
115+
for root, _, files in os.walk(safe_project_directory):
116+
for file_name in files:
117+
if file_name != "pyproject.toml":
118+
continue
119+
120+
manifest_path = _safe_under(safe_project_directory, os.path.join(root, file_name))
121+
_find_project_links(manifest_path, results, allowed)
122+
123+
output_directory = os.path.dirname(output_path)
124+
if output_directory:
125+
os.makedirs(output_directory, exist_ok=True)
126+
127+
with open(output_path, "w", encoding="utf-8") as output_file:
128+
for url in sorted(results):
129+
output_file.write(f"{url}\n")
130+
131+
return 0
132+
133+
134+
if __name__ == "__main__":
135+
sys.exit(_main())

check-project-links/action.yml

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: Check project links
2+
description: Find URLs referenced in pyproject.toml files and fail only when a response is 4xx.
3+
4+
inputs:
5+
project-directory:
6+
description: Path to the directory containing pyproject.toml files.
7+
default: ${{ github.workspace }}
8+
allowed-domains:
9+
description: Comma-separated list of trusted hostnames or domains to validate. Supports wildcards like *.readthedocs.io.
10+
default: github.com,ni.github.io,*.readthedocs.io
11+
docker-image:
12+
description: Docker image used to validate each discovered URL.
13+
default: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
14+
15+
runs:
16+
using: composite
17+
steps:
18+
- name: Check project links
19+
id: check-project-links
20+
shell: bash
21+
env:
22+
PROJECT_DIRECTORY: ${{ inputs.project-directory }}
23+
ALLOWED_DOMAINS: ${{ inputs.allowed-domains }}
24+
DOCKER_IMAGE: ${{ inputs.docker-image }}
25+
run: |
26+
set -euo pipefail
27+
28+
if [ ! -d "$PROJECT_DIRECTORY" ]; then
29+
echo "::error title=Check Project Links Error::Project directory '$PROJECT_DIRECTORY' does not exist."
30+
exit 1
31+
fi
32+
33+
if ! command -v docker >/dev/null 2>&1; then
34+
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."
35+
exit 1
36+
fi
37+
38+
link_file="$(mktemp -p "$RUNNER_TEMP")"
39+
cleanup() {
40+
rm -f "$link_file"
41+
}
42+
trap cleanup EXIT
43+
44+
chmod 644 "$link_file" # make the file world-readable so that the unprivileged user in the docker container can read it
45+
46+
python3 "$GITHUB_ACTION_PATH/_find_project_links.py" "$PROJECT_DIRECTORY" "$ALLOWED_DOMAINS" "$link_file"
47+
48+
if [ ! -s "$link_file" ]; then
49+
echo "No trusted project links found under $PROJECT_DIRECTORY."
50+
exit 0
51+
fi
52+
53+
echo "Found $(wc -l < "$link_file") unique trusted links:"
54+
cat "$link_file"
55+
56+
unprivileged_user=100:100 # this matches the default, but we want to be explicit about it
57+
docker run -u "${unprivileged_user}" --rm \
58+
-v "$link_file:/tmp/project_links.txt:ro" \
59+
"$DOCKER_IMAGE" \
60+
sh -ec '
61+
failed=0
62+
while IFS= read -r url; do
63+
[ -n "$url" ] || continue
64+
status=$(curl -L -sS --connect-timeout 5 --max-time 20 -o /tmp/link_body -w "%{http_code}" "$url" || true)
65+
case "$status" in
66+
2??|3??)
67+
echo "PASS $url -> $status"
68+
;;
69+
4??)
70+
echo "FAIL $url -> $status"
71+
failed=1
72+
;;
73+
*)
74+
echo "WARN $url -> $status"
75+
;;
76+
esac
77+
done < /tmp/project_links.txt
78+
exit "$failed"
79+
'
80+
81+
# `docker run` failures already cause this step to fail due to `set -e`.
82+
83+
echo "Success: No checked project links returned 4xx responses."

0 commit comments

Comments
 (0)