add check-project-links action - #93
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The new action and its tests have a few concrete security/robustness issues (notably eval usage, allowlist matching semantics, timeout handling, and doc/test determinism mismatches) that should be addressed before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a new composite GitHub Action (check-project-links) to scan pyproject.toml files for URLs and validate that trusted links don’t return 4xx responses, plus CI coverage and documentation updates to support using the action across NI Python projects.
Changes:
- Added
check-project-linkscomposite action to discover URLs inpyproject.tomland validate them viacurlin Docker, failing on4xx. - Added action documentation and linked the new action from the repo root README.
- Added a GitHub Actions workflow job to exercise the new action.
File summaries
| File | Description |
|---|---|
| README.md | Documents the new check-project-links action in the top-level actions list. |
| check-project-links/README.md | Provides usage docs/inputs/behavior notes for the new action. |
| check-project-links/action.yml | Implements the composite action logic for link discovery, filtering, and validation in Docker. |
| .github/workflows/test_actions.yml | Adds a workflow job to test the new action behavior. |
Review details
Suppressed comments (4)
.github/workflows/test_actions.yml:286
- If switching the test URLs away from GitHub (e.g., to
httpstat.us), the action will ignore them unlessallowed-domainsincludes the new host. Setting it explicitly here makes the test intent clear and prevents accidental passes due to filtering.
- name: Check valid project links
uses: ./check-project-links
with:
project-directory: test-project
- name: Create project with a missing link
check-project-links/README.md:44
- The Behavior section says the action “Falls back to grep” and “Extracts every
https://...URL”, but the implementation falls back tofindfor locatingpyproject.tomlfiles and initially matches bothhttpandhttpsURLs (then filters outhttp://). Tweaking these bullets would align docs with the current behavior.
- Uses `rg` to locate `pyproject.toml` files beneath the configured project directory when available.
- Falls back to `grep` with a warning if `rg` is not installed; consider adding a pre-step to install `rg` for faster runtime.
- Extracts every `https://...` URL that ends at the first whitespace character.
.github/workflows/test_actions.yml:299
- This “expected to fail” case will be more deterministic if it targets a purpose-built 404 endpoint rather than relying on a specific GitHub repo path remaining 404 forever.
Broken = "https://github.com/ni/project-that-does-not-exist"
.github/workflows/test_actions.yml:308
- To ensure the failing-link test actually validates the
httpstat.usURL (instead of filtering it out as untrusted), pass the sameallowed-domainsvalue here as in the passing case.
uses: ./check-project-links
with:
project-directory: failing-project
- name: Error if the previous step didn't fail
- Files reviewed: 4/4 changed files
- Comments generated: 7
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…ps://github.com/ni/python-actions into users/mshafer-ni/add_check_project_links_action
There was a problem hiding this comment.
🟡 Changes recommended
The current ripgrep URL extraction regex is incorrect and can prevent any links from being detected, which breaks the action’s core behavior and the expected failure test.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
check-project-links/action.yml:90
host="${host%%:*}"breaks bracketed IPv6 literal hosts likehttps://[::1]/...(it becomes[), which prevents the subsequent localhost/IP-address filters from working as intended for IPv6 literals.
host="${url#*://}"
host="${host%%/*}"
host="${host%%:*}"
host="${host,,}"
host="${host%.}"
check-project-links/action.yml:144
- Same IPv6 host parsing issue as above:
host="${host%%:*}"will truncate bracketed IPv6 literals to[and the IP/loopback drop logic won’t apply.
host="${url#*://}"
host="${host%%/*}"
host="${host%%:*}"
host="${host,,}"
host="${host%.}"
- Files reviewed: 4/4 changed files
- Comments generated: 2
- Review effort level: Lite
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
| rg_available=1 | ||
| else | ||
| rg_available=0 | ||
| echo "::warning title=Check Project Links Warning::rg is not available; falling back to grep. Consider adding a pre-step to install rg for faster runtime." |
There was a problem hiding this comment.
I love ripgrep but this seems like premature optimization. If the filesystem search is slow, then the problem is likely that it's searching large directories like .git and .venv, which should be excluded.
| fi | ||
| fi | ||
| fi | ||
| done < <("${manifest_list_cmd[@]}") | sort -u > "$manifest_links" |
There was a problem hiding this comment.
I think parsing the toml would be more reliable than grepping for URLs that end in a double quote.
import pathlib
import tomllib
def get_urls(toml):
urls = []
if isinstance(toml, dict):
for v in toml.values():
urls.extend(get_urls(v))
elif isinstance(toml, list):
for v in toml:
urls.extend(get_urls(v))
elif isinstance(toml, str) and (toml.startswith("http://") or toml.startswith("https://")):
urls.append(toml)
return urls
pyproject = tomllib.loads(pathlib.Path("pyproject.toml").read_text())
print(get_urls(pyproject))
There was a problem hiding this comment.
If we were validating links in markdown and rst, it would be a different story, but I would want to use a link checker tool for those.
| : > "$link_file" | ||
| fi | ||
|
|
||
| # Explicitly drop localhost and any IP-based hostname before validation. |
There was a problem hiding this comment.
Why is this important? Why would a pyproject.toml contain this kind of link?
Also, if you're going to ignore anything that isn't in on the trusted domains list, then you will drop these anyway.
There was a problem hiding this comment.
I'm pretty sure after filtering to httpS only, that this is a no-op. However, was inclined to include it so that copilot couldn't flag any security issues.
| if printf '%s\n' "$url" | grep -q 'http://'; then | ||
| echo "::warning title=Check Project Links Warning::Found insecure HTTP link in $url. Ignoring link." | ||
| continue | ||
| fi | ||
|
|
||
| host="${url#*://}" | ||
| host="${host%%/*}" | ||
| host="${host%%:*}" | ||
| host="${host,,}" | ||
| host="${host%.}" |
There was a problem hiding this comment.
I do not like the idea of parsing URLs in bash.
There was a problem hiding this comment.
What is the required order for adding readthedocs support to a project?
- Create RTD project
- Add RTD project URL to pyproject.toml
Do you need to publish a (pre-)release? I guess not.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
It's probably fine as long as you can bring up the RTD site before publishing the package to PyPI.
…ps://github.com/ni/python-actions into users/mshafer-ni/add_check_project_links_action
| def _main() -> int: | ||
| args = _parse_args() | ||
| project_directory = args.project_directory | ||
| safe_project_directory = os.path.realpath(project_directory, strict=True) |
There was a problem hiding this comment.
No pathlib? Where is @mshafer-NI and what have you done with him? 😅
| 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) |
| 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" |
| project_directory = args.project_directory | ||
| safe_project_directory = os.path.realpath(project_directory, strict=True) | ||
| 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)) |
There was a problem hiding this comment.
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.
| link_file="$(mktemp)" | ||
| cleanup() { | ||
| rm -f "$link_file" | ||
| } | ||
| trap cleanup EXIT | ||
| chmod 0644 "$link_file" |
There was a problem hiding this comment.
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.
| link_file="$(mktemp)" | |
| cleanup() { | |
| rm -f "$link_file" | |
| } | |
| trap cleanup EXIT | |
| chmod 0644 "$link_file" | |
| link_file="$(mktemp -p \"$RUNNER_TEMP\")" |
What does this Pull Request accomplish?
Add a check-project-links action that validates that the links listed in pyproject.toml files do not return 404.
Why should this Pull Request be merged?
We want to make sure our documentation links are correct.
What testing has been done?
Added a test using to fake projects.