Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
47 changes: 47 additions & 0 deletions .github/workflows/reviewer-route-contract-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: reviewer-route-contract-ci
run-name: reviewer route contract ci / ${{ github.event_name }} / ${{ github.ref_name }}

on:
workflow_dispatch:
push:
paths:
- ".github/workflows/reviewer-route-contract-ci.yml"
- "README.md"
- "docs/**"
- "projects/**"
- "scripts/validate-reviewer-routes.py"
- "tools/sbom-diff-and-risk/README.md"
- "tools/sbom-diff-and-risk/docs/**"
- "tools/sbom-diff-and-risk/examples/**"
pull_request:
paths:
- ".github/workflows/reviewer-route-contract-ci.yml"
- "README.md"
- "docs/**"
- "projects/**"
- "scripts/validate-reviewer-routes.py"
- "tools/sbom-diff-and-risk/README.md"
- "tools/sbom-diff-and-risk/docs/**"
- "tools/sbom-diff-and-risk/examples/**"

permissions: {}

env:
REVIEWER_ROUTE_CONTRACT_PYTHON_VERSION: "3.11"

jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out repository
uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.REVIEWER_ROUTE_CONTRACT_PYTHON_VERSION }}

- name: Validate reviewer route contract
run: python scripts/validate-reviewer-routes.py
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
*.py[cod]
.pytest_cache/
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ they do not prove the same thing.
[`docs/pypi-trusted-publishing-readiness.md`](tools/sbom-diff-and-risk/docs/pypi-trusted-publishing-readiness.md)
- Production PyPI decision gate:
[`docs/pypi-production-publishing-decision.md`](tools/sbom-diff-and-risk/docs/pypi-production-publishing-decision.md)
- Reviewer route contract:
[`scripts/validate-reviewer-routes.py`](scripts/validate-reviewer-routes.py)

The TestPyPI Trusted Publishing dry-run has been validated. Production PyPI
publishing is intentionally deferred.
Expand Down
1 change: 1 addition & 0 deletions docs/reviewer-brief.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ workflows, but they are not part of the `sbom-diff-and-risk` release surface.
| What should I review for the SBOM tool? | The SBOM [reviewer path](../tools/sbom-diff-and-risk/docs/reviewer-path.md). | You have chosen the right 30-second, 5-minute, 15-minute, release, or deep-review route. |
| Can the SBOM examples be reproduced? | The SBOM [example artifact regeneration guide](../tools/sbom-diff-and-risk/docs/example-artifact-regeneration.md). | `python scripts/regenerate-example-artifacts.py --check` passes. |
| Can the released SBOM artifacts be verified? | The SBOM [verification guide](../tools/sbom-diff-and-risk/docs/verification.md). | You know whether to use checksums, release verification, or workflow artifact attestations. |
| Are the reviewer routes still valid? | The repository [reviewer route contract](../scripts/validate-reviewer-routes.py). | `python scripts/validate-reviewer-routes.py` passes. |
| What are the supporting diagnostics projects? | The supporting project entry points below and the root [README](../README.md). | You can state their data-policy boundaries and that they are separate from the SBOM release surface. |

## Supporting diagnostics entry points
Expand Down
221 changes: 221 additions & 0 deletions scripts/validate-reviewer-routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
from __future__ import annotations

import re
import sys
from pathlib import Path
from urllib.parse import unquote


REPO_ROOT = Path(__file__).resolve().parents[1]

DOCS_TO_VALIDATE = (
Path("README.md"),
Path("docs/reviewer-brief.md"),
Path("tools/sbom-diff-and-risk/docs/reviewer-path.md"),
)

REQUIRED_LINK_TARGETS = {
Path("README.md"): {
"docs/reviewer-brief.md",
"tools/sbom-diff-and-risk/docs/reviewer-path.md",
"tools/sbom-diff-and-risk/docs/reviewer-evidence-pack.md",
"projects/python-weather-diagnostics-toolkit/docs/reviewer-path.md",
},
Path("docs/reviewer-brief.md"): {
"README.md",
"tools/sbom-diff-and-risk/docs/reviewer-path.md",
"tools/sbom-diff-and-risk/docs/example-artifact-regeneration.md",
"projects/python-weather-diagnostics-toolkit/docs/reviewer-path.md",
},
Path("tools/sbom-diff-and-risk/docs/reviewer-path.md"): {
"tools/sbom-diff-and-risk/docs/reviewer-brief.md",
"tools/sbom-diff-and-risk/docs/reviewer-evidence-pack.md",
"tools/sbom-diff-and-risk/docs/verification.md",
"tools/sbom-diff-and-risk/examples/sample-report.json",
"tools/sbom-diff-and-risk/examples/sample-summary.json",
"tools/sbom-diff-and-risk/examples/sample-policy.json",
"tools/sbom-diff-and-risk/examples/sample-sarif.sarif",
},
}

REQUIRED_TEXT = {
Path("README.md"): (
"current flagship tool",
"not part of the `sbom-diff-and-risk` release surface",
"Production PyPI publishing: intentionally deferred",
),
Path("docs/reviewer-brief.md"): (
"The current flagship project is",
"supporting diagnostics projects",
"production PyPI publishing remains intentionally deferred",
),
Path("tools/sbom-diff-and-risk/docs/reviewer-path.md"): (
"Artifact evidence map",
"No network",
"not current PyPI package truth",
"not current repository reputation",
"It does not decide whether a dependency is safe.",
),
}

REQUIRED_REVIEWER_PATHS = (
Path("tools/sbom-diff-and-risk/docs/reviewer-path.md"),
Path("projects/python-weather-diagnostics-toolkit/docs/reviewer-path.md"),
)

SUPPORTING_PROJECT_BOUNDARIES = {
Path("projects/precipitation-anomaly-diagnostics"): (
"README.md",
"PUBLICATION_BOUNDARIES.md",
"SANITIZATION_REPORT.md",
"docs/data-policy.md",
),
Path("projects/precipitation-anomaly-diagnostics-lab"): (
"README.md",
"SANITIZATION_REPORT.md",
"docs/data-policy.md",
),
Path("projects/python-weather-diagnostics-toolkit"): (
"README.md",
"PUBLICATION_BOUNDARIES.md",
"SANITIZATION_REPORT.md",
"docs/data-policy.md",
"docs/source-to-public-mapping.md",
),
}

INLINE_LINK_RE = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)")
REFERENCE_LINK_RE = re.compile(r"^\[[^\]]+\]:\s+(\S+)", re.MULTILINE)
URI_RE = re.compile(r"^[a-z][a-z0-9+.-]*:", re.IGNORECASE)
WHITESPACE_RE = re.compile(r"\s+")


def repo_relative(path: Path) -> str:
return path.relative_to(REPO_ROOT).as_posix()


def read_markdown(path: Path) -> str:
return (REPO_ROOT / path).read_text(encoding="utf-8")


def normalized_text(text: str) -> str:
return WHITESPACE_RE.sub(" ", text)


def local_link_target(markdown_path: Path, raw_target: str) -> Path | None:
target = raw_target.strip()
if target.startswith("<") and ">" in target:
target = target[1 : target.index(">")]
else:
target = target.split()[0]

target = unquote(target)
if not target or target.startswith("#") or URI_RE.match(target):
return None

path_part = target.split("#", 1)[0]
if not path_part:
return None

return (REPO_ROOT / markdown_path.parent / path_part).resolve()


def iter_local_links(markdown_path: Path) -> set[str]:
text = read_markdown(markdown_path)
raw_targets = INLINE_LINK_RE.findall(text)
raw_targets.extend(REFERENCE_LINK_RE.findall(text))

targets: set[str] = set()
for raw_target in raw_targets:
target = local_link_target(markdown_path, raw_target)
if target is None:
continue

try:
relative_target = repo_relative(target)
except ValueError:
targets.add(f"../{target}")
continue

targets.add(relative_target)

return targets


def validate_existing_links(markdown_path: Path, errors: list[str]) -> None:
text = read_markdown(markdown_path)
raw_targets = INLINE_LINK_RE.findall(text)
raw_targets.extend(REFERENCE_LINK_RE.findall(text))

for raw_target in raw_targets:
target = local_link_target(markdown_path, raw_target)
if target is None:
continue

try:
repo_relative(target)
except ValueError:
errors.append(f"{markdown_path}: link escapes repository: {raw_target}")
continue

if not target.exists():
errors.append(f"{markdown_path}: missing local link target: {raw_target}")


def validate_required_links(markdown_path: Path, errors: list[str]) -> None:
present_targets = iter_local_links(markdown_path)
for required in sorted(REQUIRED_LINK_TARGETS[markdown_path]):
if required not in present_targets:
errors.append(f"{markdown_path}: missing required reviewer route to {required}")


def validate_required_text(markdown_path: Path, errors: list[str]) -> None:
text = read_markdown(markdown_path)
normalized = normalized_text(text)
for phrase in REQUIRED_TEXT[markdown_path]:
if phrase not in text and normalized_text(phrase) not in normalized:
errors.append(f"{markdown_path}: missing reviewer contract phrase: {phrase!r}")


def validate_required_paths(errors: list[str]) -> None:
for path in REQUIRED_REVIEWER_PATHS:
if not (REPO_ROOT / path).is_file():
errors.append(f"missing reviewer path: {path.as_posix()}")

for project_root, required_files in SUPPORTING_PROJECT_BOUNDARIES.items():
for required_file in required_files:
path = project_root / required_file
if not (REPO_ROOT / path).is_file():
errors.append(f"missing supporting-project boundary file: {path.as_posix()}")


def main() -> int:
errors: list[str] = []

for markdown_path in DOCS_TO_VALIDATE:
if not (REPO_ROOT / markdown_path).is_file():
errors.append(f"missing reviewer document: {markdown_path.as_posix()}")
continue

validate_existing_links(markdown_path, errors)
validate_required_links(markdown_path, errors)
validate_required_text(markdown_path, errors)

validate_required_paths(errors)

if errors:
print("Reviewer route validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
return 1

print(
"Reviewer route validation passed: "
f"{len(DOCS_TO_VALIDATE)} documents and "
f"{len(REQUIRED_REVIEWER_PATHS)} reviewer paths checked."
)
return 0


if __name__ == "__main__":
raise SystemExit(main())