feat(release): enforce aligned trusted publications
This commit is contained in:
99
tests/test_release_catalog_publication.py
Normal file
99
tests/test_release_catalog_publication.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||
|
||||
from govoplan_release.publisher import publish_catalog_candidate # noqa: E402
|
||||
|
||||
|
||||
class ReleaseCatalogPublicationTests(unittest.TestCase):
|
||||
def test_publication_requires_an_existing_trust_anchor(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
candidate = self._candidate(root, key="candidate-key")
|
||||
web_root = root / "website"
|
||||
web_root.mkdir()
|
||||
|
||||
with patch(
|
||||
"govoplan_release.publisher.validate_module_package_catalog",
|
||||
return_value={"valid": True, "warnings": [], "error": None},
|
||||
):
|
||||
result = publish_catalog_candidate(
|
||||
candidate_dir=candidate,
|
||||
web_root=web_root,
|
||||
workspace_root=root,
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", result.status)
|
||||
self.assertIn("publication trust anchor is missing", " ".join(result.notes))
|
||||
|
||||
def test_publication_rejects_rebinding_an_existing_key_id(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
candidate = self._candidate(root, key="replacement-key")
|
||||
web_root = root / "website"
|
||||
target_keyring = web_root / "public" / "catalogs" / "v1" / "keyring.json"
|
||||
target_keyring.parent.mkdir(parents=True)
|
||||
target_keyring.write_text(json.dumps(self._keyring("trusted-key")))
|
||||
|
||||
with patch(
|
||||
"govoplan_release.publisher.validate_module_package_catalog",
|
||||
return_value={"valid": True, "warnings": [], "error": None},
|
||||
):
|
||||
result = publish_catalog_candidate(
|
||||
candidate_dir=candidate,
|
||||
web_root=web_root,
|
||||
workspace_root=root,
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", result.status)
|
||||
self.assertIn("changes the public key", " ".join(result.notes))
|
||||
|
||||
@staticmethod
|
||||
def _candidate(root: Path, *, key: str) -> Path:
|
||||
candidate = root / "candidate"
|
||||
channel = candidate / "channels"
|
||||
channel.mkdir(parents=True)
|
||||
channel.joinpath("stable.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"channel": "stable",
|
||||
"core_release": {
|
||||
"version": "1.2.3",
|
||||
"python_ref": (
|
||||
"govoplan-core @ git+ssh://git@example.test/acme/"
|
||||
"govoplan-core.git@v1.2.3"
|
||||
),
|
||||
},
|
||||
"modules": [],
|
||||
}
|
||||
)
|
||||
)
|
||||
candidate.joinpath("keyring.json").write_text(json.dumps(ReleaseCatalogPublicationTests._keyring(key)))
|
||||
return candidate
|
||||
|
||||
@staticmethod
|
||||
def _keyring(key: str) -> dict[str, object]:
|
||||
return {
|
||||
"keys": [
|
||||
{
|
||||
"key_id": "release-key",
|
||||
"status": "active",
|
||||
"public_key": key,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -51,6 +51,22 @@ class ReleaseDoctorTests(unittest.TestCase):
|
||||
self.assertEqual("repo-dirty", findings[0].check_id)
|
||||
self.assertIn("git status --short", [item.command for item in findings[0].commands])
|
||||
|
||||
def test_repo_version_mismatch_blocks_release(self) -> None:
|
||||
doctor = load_doctor_module()
|
||||
repo = doctor.RepoState(
|
||||
name="govoplan-files",
|
||||
path="/tmp/govoplan-files",
|
||||
exists=True,
|
||||
pyproject_version="0.1.9",
|
||||
package_version="0.1.8",
|
||||
webui_package_version="0.1.9",
|
||||
)
|
||||
|
||||
findings = doctor.repo_findings((repo,), target_version="0.1.9", target_tag="v0.1.9")
|
||||
|
||||
mismatch = next(item for item in findings if item.check_id == "repo-version-mismatch")
|
||||
self.assertEqual("blocker", mismatch.severity)
|
||||
|
||||
def test_dubious_ownership_errors_suggest_safe_directory_command(self) -> None:
|
||||
doctor = load_doctor_module()
|
||||
repo = doctor.RepoState(
|
||||
|
||||
60
tests/test_release_entrypoint_gates.py
Normal file
60
tests/test_release_entrypoint_gates.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class ReleaseEntrypointGateTests(unittest.TestCase):
|
||||
def test_lockstep_release_includes_every_registered_module_and_connector(self) -> None:
|
||||
script = (META_ROOT / "tools" / "release" / "push-release-tag.sh").read_text()
|
||||
registry = json.loads((META_ROOT / "repositories.json").read_text())
|
||||
|
||||
expected = {
|
||||
item["name"]
|
||||
for item in registry["repositories"]
|
||||
if item["category"] not in {"system", "website"}
|
||||
}
|
||||
missing = sorted(repo for repo in expected if f'$PARENT/{repo}"' not in script)
|
||||
|
||||
self.assertEqual([], missing)
|
||||
|
||||
def test_lockstep_release_runs_source_and_full_gates_before_remote_push(self) -> None:
|
||||
script = (META_ROOT / "tools" / "release" / "push-release-tag.sh").read_text()
|
||||
workflow = script.rsplit("\nconfirm_release\n", 1)[1]
|
||||
|
||||
source_gate = workflow.index("run_version_alignment_gate source")
|
||||
first_commit = workflow.index('run git -C "$repo" commit')
|
||||
lock_generation = workflow.index("generate_release_lock")
|
||||
full_gate = workflow.index("run_version_alignment_gate", source_gate + 1)
|
||||
first_push = workflow.index('run git -C "$repo" push')
|
||||
|
||||
self.assertLess(source_gate, first_commit)
|
||||
self.assertLess(first_commit, lock_generation)
|
||||
self.assertLess(lock_generation, full_gate)
|
||||
self.assertLess(full_gate, first_push)
|
||||
|
||||
def test_source_catalog_generator_enforces_explicit_repo_versions(self) -> None:
|
||||
script = (META_ROOT / "tools" / "release" / "generate-release-catalog.py").read_text()
|
||||
|
||||
gate = script.index("selected_repository_version_issues(")
|
||||
write = script.index("output.write_text(")
|
||||
|
||||
self.assertLess(gate, write)
|
||||
|
||||
def test_candidate_publication_uses_existing_keyring_as_trust_anchor(self) -> None:
|
||||
publisher = (META_ROOT / "tools" / "release" / "govoplan_release" / "publisher.py").read_text()
|
||||
|
||||
self.assertIn("candidate_catalog_version_issues(candidate_payload)", publisher)
|
||||
self.assertIn("trusted_keys_from_keyring(\n target_keyring_payload", publisher)
|
||||
self.assertIn("trusted_keys=publication_trust", publisher)
|
||||
self.assertNotIn("trusted_keys=candidate_keys", publisher)
|
||||
self.assertIn("write_module_directory(", publisher)
|
||||
self.assertNotIn("shutil.copytree(candidate_modules", publisher)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
184
tests/test_version_alignment.py
Normal file
184
tests/test_version_alignment.py
Normal file
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||
|
||||
from govoplan_release.version_alignment import ( # noqa: E402
|
||||
candidate_catalog_version_issues,
|
||||
release_composition_issues,
|
||||
repository_version_issues,
|
||||
selected_repository_version_issues,
|
||||
)
|
||||
from govoplan_release.model import RepositorySnapshot, RepositorySpec, VersionSnapshot # noqa: E402
|
||||
from govoplan_release.selective_planner import build_unit # noqa: E402
|
||||
from govoplan_release.selective_catalog import enforce_selected_version_alignment # noqa: E402
|
||||
|
||||
|
||||
class VersionAlignmentTests(unittest.TestCase):
|
||||
def test_candidate_catalog_refs_and_selected_units_must_match_versions(self) -> None:
|
||||
payload = {
|
||||
"core_release": {
|
||||
"version": "1.2.3",
|
||||
"python_ref": "govoplan-core @ git+ssh://git@example.test/acme/govoplan-core.git@v1.2.3",
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"module_id": "files",
|
||||
"version": "1.2.3",
|
||||
"python_ref": "govoplan-files @ git+ssh://git@example.test/acme/govoplan-files.git@v1.2.4",
|
||||
"webui_package": "@govoplan/files-webui",
|
||||
"webui_ref": "git+ssh://git@example.test/acme/govoplan-files.git#v1.2.3",
|
||||
}
|
||||
],
|
||||
"release": {
|
||||
"selected_units": [
|
||||
{"repo": "govoplan-files", "version": "1.2.5", "tag": "v1.2.6"},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
issues = candidate_catalog_version_issues(payload)
|
||||
|
||||
messages = {issue.message for issue in issues}
|
||||
self.assertIn("Python ref tag must match the catalog entry version", messages)
|
||||
self.assertIn("selected-unit tag must match its version", messages)
|
||||
self.assertIn("selected-unit version must match the catalog entry", messages)
|
||||
|
||||
def test_catalog_candidate_gate_rejects_selected_repo_drift(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
workspace = Path(tmp)
|
||||
repo = workspace / "govoplan-files"
|
||||
(repo / "webui").mkdir(parents=True)
|
||||
(repo / "pyproject.toml").write_text('[project]\nname="govoplan-files"\nversion="1.2.3"\n')
|
||||
(repo / "webui" / "package.json").write_text('{"version":"1.2.4"}\n')
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "Version alignment gate failed"):
|
||||
enforce_selected_version_alignment(
|
||||
repo_versions={"govoplan-files": "1.2.5"},
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
def test_catalog_candidate_gate_rejects_requested_version_that_differs_from_source(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
workspace = Path(tmp)
|
||||
repo = workspace / "govoplan-files"
|
||||
(repo / "webui").mkdir(parents=True)
|
||||
(repo / "pyproject.toml").write_text('[project]\nname="govoplan-files"\nversion="1.2.3"\n')
|
||||
(repo / "webui" / "package.json").write_text('{"version":"1.2.3"}\n')
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "source version must match the requested release version"):
|
||||
enforce_selected_version_alignment(
|
||||
repo_versions={"govoplan-files": "1.2.4"},
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
def test_selected_repository_gate_reports_missing_version_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
workspace = Path(tmp)
|
||||
(workspace / "govoplan-files").mkdir()
|
||||
|
||||
issues = selected_repository_version_issues(
|
||||
repo_versions={"govoplan-files": "1.2.3"},
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(issues))
|
||||
self.assertEqual("repository has no version metadata", issues[0].message)
|
||||
|
||||
def test_selective_release_unit_is_blocked_by_version_drift(self) -> None:
|
||||
repo = RepositorySnapshot(
|
||||
spec=RepositorySpec(
|
||||
name="govoplan-example",
|
||||
category="module",
|
||||
subtype="domain",
|
||||
remote="git@example.test:acme/govoplan-example.git",
|
||||
path="govoplan-example",
|
||||
),
|
||||
absolute_path="/tmp/govoplan-example",
|
||||
exists=True,
|
||||
is_git=True,
|
||||
has_head=True,
|
||||
versions=VersionSnapshot(pyproject="1.2.3", webui_package="1.2.4", manifests=("1.2.3",)),
|
||||
)
|
||||
|
||||
unit = build_unit(repo, target_version="1.2.5", contracts=None)
|
||||
|
||||
self.assertEqual("blocked", unit.status)
|
||||
self.assertIn("version metadata is not aligned", unit.blockers[0])
|
||||
|
||||
def test_repository_versions_and_lockfiles_must_match(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp) / "govoplan-example"
|
||||
(root / "src" / "govoplan_example" / "backend").mkdir(parents=True)
|
||||
(root / "webui").mkdir()
|
||||
(root / "pyproject.toml").write_text('[project]\nname = "govoplan-example"\nversion = "1.2.3"\n')
|
||||
(root / "package.json").write_text('{"name":"@govoplan/example","version":"1.2.3"}\n')
|
||||
(root / "webui" / "package.json").write_text('{"name":"@govoplan/example-webui","version":"1.2.4"}\n')
|
||||
(root / "webui" / "package-lock.json").write_text(
|
||||
json.dumps({"packages": {"": {"version": "1.2.2"}}})
|
||||
)
|
||||
(root / "src" / "govoplan_example" / "backend" / "manifest.py").write_text(
|
||||
'manifest = ModuleManifest(\n version="1.2.3",\n)\n'
|
||||
)
|
||||
|
||||
issues = repository_version_issues(root)
|
||||
|
||||
self.assertEqual(2, len(issues))
|
||||
self.assertEqual({"webui/package.json", "webui/package-lock.json"}, {issue.source for issue in issues})
|
||||
|
||||
def test_public_runtime_version_must_match_package_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp) / "govoplan-example"
|
||||
package = root / "src" / "govoplan_example"
|
||||
package.mkdir(parents=True)
|
||||
(root / "pyproject.toml").write_text(
|
||||
'[project]\nname = "govoplan-example"\nversion = "1.2.3"\n'
|
||||
)
|
||||
(package / "__init__.py").write_text('__version__ = "1.2.2"\n')
|
||||
|
||||
issues = repository_version_issues(root)
|
||||
|
||||
self.assertEqual(1, len(issues))
|
||||
self.assertEqual("package __init__.py 1", issues[0].source)
|
||||
self.assertEqual("1.2.3", issues[0].expected)
|
||||
self.assertEqual("1.2.2", issues[0].actual)
|
||||
|
||||
def test_release_backend_and_frontend_refs_must_match(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
meta = Path(tmp) / "govoplan"
|
||||
core = Path(tmp) / "govoplan-core"
|
||||
meta.mkdir()
|
||||
(core / "webui").mkdir(parents=True)
|
||||
(meta / "requirements-release.txt").write_text(
|
||||
"govoplan-example @ git+ssh://git@example.test/acme/govoplan-example.git@v1.2.3\n"
|
||||
)
|
||||
(core / "webui" / "package.release.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"dependencies": {
|
||||
"@govoplan/example-webui": (
|
||||
"git+ssh://git@example.test/acme/govoplan-example.git#v1.2.4"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
issues = release_composition_issues(meta, core_root=core)
|
||||
|
||||
self.assertEqual(1, len(issues))
|
||||
self.assertEqual("1.2.3", issues[0].expected)
|
||||
self.assertEqual("1.2.4", issues[0].actual)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user