Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e3c6568
copilot: first draft
mshafer-NI Sep 2, 2026
ab0adce
copilot: setup tests
mshafer-NI Sep 2, 2026
705a350
fix zizmor issues
mshafer-NI Sep 2, 2026
30bef4f
use @v0 in doc and add disclaimer
mshafer-NI Sep 2, 2026
6116fb7
don't care about the full link for http warning
mshafer-NI Sep 2, 2026
fd83fa7
also handle quote ended links
mshafer-NI Sep 2, 2026
44028ac
fix copilot suggested issues
mshafer-NI Sep 2, 2026
882ba28
add trusted domains input and limit to only checking https links at t…
mshafer-NI Sep 2, 2026
c04646d
add checks for rg and docker commands
mshafer-NI Sep 2, 2026
e3d97a1
whitespace
mshafer-NI Sep 2, 2026
35617bd
Apply batched suggestions from code review
mshafer-NI Sep 2, 2026
37f96fb
Merge branch 'users/mshafer-ni/add_check_project_links_action' of htt…
mshafer-NI Sep 2, 2026
c7b1ff9
explicitly drop loopback and IP based hosts
mshafer-NI Sep 2, 2026
bd73f6a
Apply batched suggestions from code review
mshafer-NI Sep 2, 2026
8943bd4
use existing env vars directly
mshafer-NI Sep 2, 2026
b61454c
move it to a py file
mshafer-NI Sep 2, 2026
0e3d049
Merge branch 'users/mshafer-ni/add_check_project_links_action' of htt…
mshafer-NI Sep 2, 2026
a772e33
update doc
mshafer-NI Sep 2, 2026
1ef1d05
switch to python impl
mshafer-NI Sep 2, 2026
4c19544
all private
mshafer-NI Sep 2, 2026
d9ead73
fix docstring
mshafer-NI Sep 2, 2026
d37c307
format
mshafer-NI Sep 2, 2026
ad3ece3
also not a public module
mshafer-NI Sep 2, 2026
bcbd82f
set the file as world read so the docker user can read it
mshafer-NI Sep 2, 2026
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
58 changes: 58 additions & 0 deletions .github/workflows/test_actions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,63 @@ jobs:
project-directory: test-project
expected-version: 1.0.2.dev1

test_check_project_links:
name: Test check-project-links
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Create project with a valid link
run: |
mkdir -p test-project
cat > test-project/pyproject.toml <<'EOF'
[project]
name = "test-project"
version = "0.1.0"
description = "Test project with a valid URL"
authors = [{ name = "NI" }]
readme = "README.md"
requires-python = ">=3.9"

[project.urls]
Homepage = "https://github.com/ni/python-actions"
Documentation = "https://github.com/ni/python-actions/blob/main/README.md"
Comment thread
mshafer-NI marked this conversation as resolved.
EOF
shell: bash
- name: Check valid project links
uses: ./check-project-links
with:
project-directory: test-project
- name: Create project with a missing link
run: |
mkdir -p failing-project
cat > failing-project/pyproject.toml <<'EOF'
[project]
name = "failing-project"
version = "0.1.0"
description = "Test project with a 404 URL"
authors = [{ name = "NI" }]
readme = "README.md"
requires-python = ">=3.9"

[project.urls]
Broken = "https://github.com/ni/project-that-does-not-exist"
EOF
shell: bash
- name: Check missing project link (expected to fail)
id: expected-failure
continue-on-error: true
uses: ./check-project-links
with:
project-directory: failing-project
- name: Error if the previous step didn't fail
if: steps.expected-failure.outcome != 'failure'
run: |
echo "::error title=Test Failure::The previous step did not fail as expected."
exit 1

test_analyze_project:
name: Test analyze-project
runs-on: ${{ matrix.os }}
Expand Down Expand Up @@ -454,6 +511,7 @@ jobs:
test_setup_poetry_no_cache,
test_check_project_version,
test_update_project_version,
test_check_project_links,
test_analyze_project,
test_analyze_project_repo_root,
]
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
- [`ni/python-actions/check-project-version`](check-project-version): uses Poetry to get the version
of a Python project and checks that it matches an expected version. Publish workflows can use this
to verify that the release tag matches the version number in `pyproject.toml`.
- [`ni/python-actions/check-project-links`](check-project-links): scans project `pyproject.toml` files
for URLs, writes the discovered links to a temporary file, and validates each URL in Docker while
failing only on `4xx` responses.
- [`ni/python-actions/update-project-version`](update-project-version): uses Poetry to update the
version of a Python project and creates a pull request to modify its `pyproject.toml` file.
Publish workflows can use this to update the version in `pyproject.toml` for the next build.
55 changes: 55 additions & 0 deletions check-project-links/README.md
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.
157 changes: 157 additions & 0 deletions check-project-links/_find_project_links.py
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 project_directory points to the project directory. All of our other actions do that.

I also think that requiring the output path to be under /tmp is a bad assumption. $TMPDIR might point to /var/tmp/$USER or something. $RUNNER_TEMP might point somewhere else entirely. Also, requiring /tmp makes it hard to test the script on Windows.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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())
94 changes: 94 additions & 0 deletions check-project-links/action.yml

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the required order for adding readthedocs support to a project?

  1. Create RTD project
  2. Add RTD project URL to pyproject.toml

Do you need to publish a (pre-)release? I guess not.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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:
publish 1.0 (with docs on rtd, without link to them)
update source to include links to rtd
publish 1.0.1

(or similar)..., which is admittedly not great...

Another option:
Set this to not scan rtd
publish (including to rtd) with a link that's not valid in source until the publish happens
Update code for default allowed-domains

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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"
}
Comment thread
mshafer-NI marked this conversation as resolved.
trap cleanup EXIT
chmod 0644 "$link_file"
Comment on lines +38 to +43

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 $RUNNER_TEMP.

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
link_file="$(mktemp)"
cleanup() {
rm -f "$link_file"
}
trap cleanup EXIT
chmod 0644 "$link_file"
link_file="$(mktemp -p \"$RUNNER_TEMP\")"


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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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."
Loading