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
2 changes: 1 addition & 1 deletion .github/workflows/python-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- name: Install editable package
run: |
python -m pip install --upgrade pip
python -m pip install -e .
python -m pip install -e ".[dev]"
- name: Verify import
run: |
python -c "import repograph"
Expand Down
3 changes: 1 addition & 2 deletions .hooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ workspace_root="$(cd "$repo_root/.." && pwd)"
if [ -z "${REPOGRAPH_BOUNDARY_ARTIFACT_FILE:-}" ]; then
shopt -s nullglob
for candidate in \
"$workspace_root"/PrivateManifest/dist/boundary_disclosure_artifact.json \
"$workspace_root"/*/dist/boundary_disclosure_artifact.json \
"$workspace_root"/*/policy/boundary_disclosure_artifact.json
do
Expand All @@ -22,7 +21,7 @@ fi

if [ -z "${REPOGRAPH_BOUNDARY_ARTIFACT_FILE:-}" ] || [ ! -f "$REPOGRAPH_BOUNDARY_ARTIFACT_FILE" ]; then
echo "[custodian-pre-push] missing REPOGRAPH_BOUNDARY_ARTIFACT_FILE; failing closed"
echo " Generate artifact from PrivateManifest first:"
echo " Generate artifact from your private-manifest repo first:"
echo " python -m private_manifest.export_boundary_artifact --graph-root graph --out dist/boundary_disclosure_artifact.json"
exit 1
fi
Expand Down
8 changes: 7 additions & 1 deletion src/repograph/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
"""Shared RepoGraph public API."""

from .errors import AmbiguousAnchorError, RepoGraphConfigError
from .registry import Registry, default_registry_path, discover_manifest_yaml
from .registry import (
Registry,
default_registry_path,
discover_manifest_yaml,
resolve_private_manifest,
)
from .authorization import (
AlsoHostsEntry,
AuthorizationView,
Expand Down Expand Up @@ -78,6 +83,7 @@
"build_authorization_view",
"default_registry_path",
"discover_manifest_yaml",
"resolve_private_manifest",
"BoundaryDisclosureArtifact",
"BreakingChangePolicy",
"CompatibilityPolicy",
Expand Down
52 changes: 47 additions & 5 deletions src/repograph/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
Schema::

manifests:
- /home/dev/Documents/GitHub/PlatformManifest
- /home/dev/Documents/GitHub/PrivateManifest
- /path/to/PlatformManifest
- /path/to/<your-private-manifest-repo>
"""

from __future__ import annotations
Expand Down Expand Up @@ -197,9 +197,8 @@ def discover_manifest_yaml(repo_root: Path) -> Path | None:
def discover_all_manifest_yamls(repo_root: Path) -> list[Path]:
"""Return all manifest YAMLs under a manifest repo root (in glob order).

A single private manifest repo (e.g. PrivateManifest) may host more than
one manifest YAML (e.g. one per managed project). Authorization needs all
of them.
A single private manifest repo may host more than one manifest YAML
(e.g. one per managed project). Authorization needs all of them.
"""
seen: set[Path] = set()
results: list[Path] = []
Expand All @@ -215,6 +214,49 @@ def discover_all_manifest_yamls(repo_root: Path) -> list[Path]:
return results


# ----------------------------------------------------------------------
# Private-manifest role resolution
# ----------------------------------------------------------------------

_PRIVATE_MANIFEST_ENV = "PRIVATE_MANIFEST_DIR"
_PRIVATE_MANIFEST_PREFIX = "private_manifest"


def resolve_private_manifest(registry: "Registry | None" = None) -> Path | None:
"""Resolve the active private-manifest repo root, or ``None``.

The platform references the private-manifest *role*, never a baked-in
repo-instance name. Resolution order:

1. ``$PRIVATE_MANIFEST_DIR`` — explicit operator override (must be an
existing directory; a set-but-bogus value resolves to ``None`` rather
than silently falling through to discovery).
2. Registry discovery — the first registered manifest repo root hosting
any manifest YAML whose basename starts with ``private_manifest``
(matched on the manifest *type* filename, never a repo name).

Best-effort by design: callers that require a private manifest should
treat ``None`` as their own failure condition.
"""
env = os.environ.get(_PRIVATE_MANIFEST_ENV, "").strip()
if env:
root = Path(env).expanduser()
return root.resolve() if root.is_dir() else None
if registry is None:
try:
registry = Registry.load()
except RepoGraphConfigError:
return None
for root in registry.manifests:
root = Path(root).expanduser()
if not root.is_dir():
continue
yamls = discover_all_manifest_yamls(root)
if any(y.name.startswith(_PRIVATE_MANIFEST_PREFIX) for y in yamls):
return root.resolve()
return None


def iter_yaml_documents(paths: Iterable[Path]) -> list[tuple[Path, dict]]:
"""Load YAML docs from the given paths. Skips files that don't parse to a mapping."""
results: list[tuple[Path, dict]] = []
Expand Down
2 changes: 1 addition & 1 deletion tests/test_boundary_artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def test_boundary_artifact_forbids_private_names_but_not_public_aliases() -> Non
)
artifact = build_boundary_artifact(
graph,
source_graph_id="PrivateManifest",
source_graph_id="private-manifest-fixture",
source_ref_or_commit="abc123",
)
assert artifact.schema_kind == "boundary_artifact"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_projection_redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def test_alias_only_private_repo_projects_as_public_placeholder() -> None:
)
],
)
projection = build_public_projection(graph, source_graph_id="PrivateManifest")
projection = build_public_projection(graph, source_graph_id="private-manifest-fixture")
assert projection.manifest["schema_kind"] == "projection"
assert projection.manifest["schema_version"] == "1.0.0"
assert projection.manifest["projection_profile"] == "public_safe"
Expand Down
60 changes: 60 additions & 0 deletions tests/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,63 @@ def test_iter_yaml_documents_raises_on_bad_yaml(tmp_path):
bad.write_text("key: : :\n - broken\n", encoding="utf-8")
with pytest.raises(RepoGraphConfigError, match="parse error"):
iter_yaml_documents([bad])


# ----------------------------------------------------------------------
# resolve_private_manifest — the shared private-manifest *role* resolver
# ----------------------------------------------------------------------


def test_resolve_private_manifest_env_override(tmp_path, monkeypatch):
from repograph.registry import resolve_private_manifest

privm = tmp_path / "AnyName"
privm.mkdir()
monkeypatch.setenv("PRIVATE_MANIFEST_DIR", str(privm))
assert resolve_private_manifest() == privm.resolve()


def test_resolve_private_manifest_env_bogus_returns_none(tmp_path, monkeypatch):
from repograph.registry import resolve_private_manifest

monkeypatch.setenv("PRIVATE_MANIFEST_DIR", str(tmp_path / "missing"))
assert resolve_private_manifest() is None


def test_resolve_private_manifest_via_registry(registry_env, tmp_path, monkeypatch):
from repograph.registry import resolve_private_manifest

monkeypatch.delenv("PRIVATE_MANIFEST_DIR", raising=False)
pm = tmp_path / "PublicSide"
_write_public_manifest(pm, "PM", ["pubA"])
# The private repo's NAME is arbitrary — resolution matches the manifest
# type filename (private_manifest*), never a repo-instance name.
privm = tmp_path / "ArbitraryPrivateRepoName"
_write_private_manifest(privm, "PrivM", ["privA"])
reg = Registry.load()
reg.add(pm)
reg.add(privm)
reg.save()

assert resolve_private_manifest() == privm.resolve()


def test_resolve_private_manifest_none_when_no_private(registry_env, tmp_path, monkeypatch):
from repograph.registry import resolve_private_manifest

monkeypatch.delenv("PRIVATE_MANIFEST_DIR", raising=False)
pm = tmp_path / "PublicOnly"
_write_public_manifest(pm, "PM", ["pubA"])
reg = Registry.load()
reg.add(pm)
reg.save()

assert resolve_private_manifest() is None


def test_resolve_private_manifest_none_when_registry_missing(registry_env, monkeypatch):
from repograph.registry import resolve_private_manifest

monkeypatch.delenv("PRIVATE_MANIFEST_DIR", raising=False)
# registry_env points at a nonexistent file → empty registry, no private root.
assert resolve_private_manifest() is None
19 changes: 9 additions & 10 deletions tests/test_schema_governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import pytest

from repograph import (
BoundaryDisclosureArtifact,
RepoGraphConfigError,
SchemaKind,
DisclosureMode,
Expand Down Expand Up @@ -60,7 +59,7 @@ def test_schema_versions_are_explicit_and_supported() -> None:
def test_boundary_artifact_contains_schema_and_hash() -> None:
artifact = build_boundary_artifact(
_graph(),
source_graph_id="PrivateManifest",
source_graph_id="private-manifest-fixture",
source_ref_or_commit="abc123",
)
data = artifact.to_dict()
Expand All @@ -71,7 +70,7 @@ def test_boundary_artifact_contains_schema_and_hash() -> None:


def test_projection_manifest_includes_schema_metadata() -> None:
projection = build_public_projection(_graph(), source_graph_id="PrivateManifest")
projection = build_public_projection(_graph(), source_graph_id="private-manifest-fixture")
assert projection.manifest["schema_kind"] == "projection"
assert projection.manifest["schema_version"] == "1.0.0"
assert projection.manifest["projection_profile"] == "public_safe"
Expand Down Expand Up @@ -118,10 +117,10 @@ def test_repo_graph_diff_detects_visibility_escalation() -> None:

def test_repo_graph_diff_detects_projection_profile_changes() -> None:
graph = _graph()
public_projection = build_public_projection(graph, source_graph_id="PrivateManifest")
public_projection = build_public_projection(graph, source_graph_id="private-manifest-fixture")
docs_projection = build_public_projection(
graph,
source_graph_id="PrivateManifest",
source_graph_id="private-manifest-fixture",
profile=ProjectionProfileKind.PUBLIC_DOCS,
)
diff = RepoGraphDiff.compare_public_projection(
Expand All @@ -137,12 +136,12 @@ def test_repo_graph_diff_detects_projection_profile_changes() -> None:
def test_repo_graph_diff_detects_boundary_artifact_provenance_changes() -> None:
before = build_boundary_artifact(
_graph(),
source_graph_id="PrivateManifest",
source_graph_id="private-manifest-fixture",
source_ref_or_commit="abc123",
)
after = build_boundary_artifact(
_graph(),
source_graph_id="PrivateManifest",
source_graph_id="private-manifest-fixture",
source_ref_or_commit="def456",
)
diff = RepoGraphDiff.compare_boundary_artifacts(
Expand All @@ -158,7 +157,7 @@ def test_repo_graph_diff_detects_boundary_artifact_provenance_changes() -> None:
def test_boundary_artifact_validation_rejects_hash_mismatch() -> None:
artifact = build_boundary_artifact(
_graph(),
source_graph_id="PrivateManifest",
source_graph_id="private-manifest-fixture",
source_ref_or_commit="abc123",
)
payload = artifact.to_dict()
Expand All @@ -170,13 +169,13 @@ def test_boundary_artifact_validation_rejects_hash_mismatch() -> None:
def test_boundary_artifact_validation_rejects_signature_without_verifier() -> None:
artifact = build_boundary_artifact(
_graph(),
source_graph_id="PrivateManifest",
source_graph_id="private-manifest-fixture",
source_ref_or_commit="abc123",
)
payload = artifact.to_dict()
payload["signature"] = "signed"
payload["signature_algorithm"] = "ed25519"
payload["issuer"] = "PrivateManifest"
payload["issuer"] = "private-manifest-fixture"
payload["signed_at"] = "2026-05-13T00:00:00Z"
with pytest.raises(RepoGraphConfigError):
validate_boundary_artifact_payload(payload)
Loading