Harden source release preparation and record verified security follow-up
This commit is contained in:
@@ -780,6 +780,11 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
self.assertIn("redis", compose["services"])
|
||||
self.assertIn("worker", compose["services"])
|
||||
self.assertIn("load-balancer", compose["services"])
|
||||
self.assertEqual(
|
||||
"haproxy:3.2.23-alpine@sha256:"
|
||||
"6343ce34a132a5dceaa24767d739df2bd519f8f7c1079ae39e4821334e8eb42e",
|
||||
compose["services"]["load-balancer"]["image"],
|
||||
)
|
||||
self.assertNotIn("test-mail", compose["services"])
|
||||
self.assertEqual(
|
||||
["127.0.0.1:8080:8080"],
|
||||
@@ -1145,8 +1150,26 @@ class DeploymentInstallerTests(unittest.TestCase):
|
||||
self.assertEqual(1, parsed.replicas.web)
|
||||
self.assertEqual(1, parsed.replicas.worker)
|
||||
self.assertEqual("managed", parsed.components.load_balancer.mode)
|
||||
self.assertEqual(
|
||||
default_spec().components.load_balancer.image,
|
||||
parsed.components.load_balancer.image,
|
||||
)
|
||||
self.assertEqual("local", parsed.ingress.mode)
|
||||
|
||||
def test_load_balancer_patch_does_not_rewrite_an_existing_image(self) -> None:
|
||||
for image in (
|
||||
"haproxy:3.2.21-alpine",
|
||||
"registry.example.test/haproxy@sha256:" + "a" * 64,
|
||||
):
|
||||
with self.subTest(image=image):
|
||||
saved = default_spec(load_balancer_image=image).to_dict()
|
||||
|
||||
restored = parse_spec(json.loads(json.dumps(saved)))
|
||||
compose = render_compose(restored)
|
||||
|
||||
self.assertEqual(image, restored.components.load_balancer.image)
|
||||
self.assertEqual(image, compose["services"]["load-balancer"]["image"])
|
||||
|
||||
def test_compose_contains_no_secret_values(self) -> None:
|
||||
spec = default_spec()
|
||||
values = initial_secrets(spec)
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Local mixed-owner admission/recovery evidence, not production capacity certification.
|
||||
|
||||
All inputs are synthetic and held in memory. The spawn observer temporarily
|
||||
holds the admitted parent's handshake so the other owners encounter the same
|
||||
occupied slot deterministically. Children perform real XLSX, template and
|
||||
Dataflow work; there is no mocked process execution, database, or live service.
|
||||
The non-queuing gate promises retryable rejection, not scheduler fairness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from io import BytesIO
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
from govoplan_connectors.backend.tabular_adapters import (
|
||||
parse_managed_tabular_content,
|
||||
)
|
||||
from govoplan_core.core.templates import TemplateRenderRequest
|
||||
from govoplan_core.security import bounded_process
|
||||
from govoplan_core.security.bounded_process import ProcessBudgetError
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_dataflow.backend.backends import execute_typed_graph
|
||||
from govoplan_dataflow.backend.schemas import (
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
GraphPosition,
|
||||
PipelineGraph,
|
||||
)
|
||||
from govoplan_templates.backend.rendering import _render_payload
|
||||
except ImportError as exc:
|
||||
raise unittest.SkipTest(
|
||||
"Mixed-owner isolation requires the optional module test environment."
|
||||
) from exc
|
||||
|
||||
|
||||
class IsolatedWorkCompositionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
workbook = Workbook()
|
||||
workbook.active.append(["name"])
|
||||
workbook.active.append(["Ada"])
|
||||
stream = BytesIO()
|
||||
workbook.save(stream)
|
||||
workbook.close()
|
||||
self.workbook = stream.getvalue()
|
||||
self.graph = PipelineGraph(
|
||||
nodes=[
|
||||
GraphNode(
|
||||
id="source",
|
||||
type="source.inline",
|
||||
label="Source",
|
||||
position=GraphPosition(x=0, y=0),
|
||||
config={"source_name": "records", "rows": [{"name": "Ada"}]},
|
||||
),
|
||||
GraphNode(
|
||||
id="output",
|
||||
type="output",
|
||||
label="Output",
|
||||
position=GraphPosition(x=100, y=0),
|
||||
config={},
|
||||
),
|
||||
],
|
||||
edges=[GraphEdge(id="edge", source="source", target="output")],
|
||||
)
|
||||
|
||||
def xlsx(self):
|
||||
rows, sheet = parse_managed_tabular_content(
|
||||
self.workbook,
|
||||
filename="synthetic.xlsx",
|
||||
content_type=None,
|
||||
delimiter=",",
|
||||
sheet_name=None,
|
||||
)
|
||||
self.assertEqual(rows, ({"name": "Ada"},))
|
||||
self.assertEqual(sheet, "Sheet")
|
||||
return "xlsx"
|
||||
|
||||
def templates(self):
|
||||
payload, content_type, _pages = _render_payload(
|
||||
SimpleNamespace(name="Synthetic template"),
|
||||
SimpleNamespace(
|
||||
content_text="Hello {{item.name}}",
|
||||
content_html=None,
|
||||
template_type="letter",
|
||||
layout={},
|
||||
output_profiles=[],
|
||||
),
|
||||
request=TemplateRenderRequest(
|
||||
template_id="synthetic", output_format="text"
|
||||
),
|
||||
items=({"name": "Ada"},),
|
||||
)
|
||||
self.assertEqual(payload, b"Hello Ada")
|
||||
self.assertEqual(content_type, "text/plain; charset=utf-8")
|
||||
return "templates"
|
||||
|
||||
def dataflow(self):
|
||||
result = execute_typed_graph(self.graph, backend="reference")
|
||||
self.assertEqual(result.rows, [{"name": "Ada"}])
|
||||
return "dataflow"
|
||||
|
||||
def test_one_shared_slot_rejects_other_owners_and_all_retries_recover(self):
|
||||
owners = {
|
||||
"xlsx": self.xlsx,
|
||||
"templates": self.templates,
|
||||
"dataflow": self.dataflow,
|
||||
}
|
||||
processes = []
|
||||
modules = []
|
||||
hold_next = False
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
observer_lock = threading.Lock()
|
||||
maximum_unreaped = 0
|
||||
observer_timeouts = 0
|
||||
original_popen = bounded_process.subprocess.Popen
|
||||
|
||||
def observe_spawn(*args, **kwargs):
|
||||
nonlocal hold_next, maximum_unreaped, observer_timeouts
|
||||
process = original_popen(*args, **kwargs)
|
||||
with observer_lock:
|
||||
processes.append(process)
|
||||
modules.append(args[0][5])
|
||||
maximum_unreaped = max(
|
||||
maximum_unreaped, sum(item.returncode is None for item in processes)
|
||||
)
|
||||
should_hold = hold_next
|
||||
hold_next = False
|
||||
if should_hold:
|
||||
entered.set()
|
||||
if not release.wait(8):
|
||||
# Return control so the real runner's normal timeout and
|
||||
# process-group cleanup still own this child on test error.
|
||||
observer_timeouts += 1
|
||||
return process
|
||||
|
||||
def rejected(operation):
|
||||
try:
|
||||
operation()
|
||||
except Exception as exc:
|
||||
cause = exc
|
||||
while cause is not None and not isinstance(cause, ProcessBudgetError):
|
||||
cause = cause.__cause__
|
||||
self.assertIsInstance(cause, ProcessBudgetError)
|
||||
self.assertEqual(cause.code, "busy")
|
||||
return "busy"
|
||||
self.fail(
|
||||
"A different module admitted work while the shared slot was occupied."
|
||||
)
|
||||
|
||||
started = time.monotonic()
|
||||
busy_count = 0
|
||||
try:
|
||||
with (
|
||||
patch.object(settings, "isolated_process_concurrency", 1),
|
||||
patch.object(bounded_process.subprocess, "Popen", observe_spawn),
|
||||
ThreadPoolExecutor(max_workers=3) as executor,
|
||||
):
|
||||
for owner, operation in owners.items():
|
||||
with self.subTest(admitted_owner=owner):
|
||||
entered.clear()
|
||||
release.clear()
|
||||
hold_next = True
|
||||
holder = executor.submit(operation)
|
||||
try:
|
||||
self.assertTrue(
|
||||
entered.wait(5),
|
||||
"The admitted operation never spawned its real child.",
|
||||
)
|
||||
children_before = len(processes)
|
||||
others = [
|
||||
work for label, work in owners.items() if label != owner
|
||||
]
|
||||
denied = [
|
||||
executor.submit(rejected, work) for work in others
|
||||
]
|
||||
self.assertEqual(
|
||||
[future.result(timeout=5) for future in denied],
|
||||
["busy", "busy"],
|
||||
)
|
||||
busy_count += len(denied)
|
||||
self.assertEqual(len(processes), children_before)
|
||||
finally:
|
||||
release.set()
|
||||
self.assertEqual(holder.result(timeout=15), owner)
|
||||
self.assertEqual(bounded_process._active, 0)
|
||||
# Every rejected owner is retried through its real API.
|
||||
# Each must complete after the previous holder releases.
|
||||
for other in others:
|
||||
other()
|
||||
self.assertEqual(bounded_process._active, 0)
|
||||
finally:
|
||||
release.set()
|
||||
for process in processes:
|
||||
self.assertIsNotNone(process.returncode, "Worker was not reaped.")
|
||||
self.assertTrue(
|
||||
all(
|
||||
stream.closed
|
||||
for stream in (process.stdin, process.stdout, process.stderr)
|
||||
)
|
||||
)
|
||||
with self.assertRaises(ChildProcessError):
|
||||
os.waitpid(process.pid, os.WNOHANG)
|
||||
self.assertEqual(maximum_unreaped, 1)
|
||||
self.assertEqual(observer_timeouts, 0)
|
||||
self.assertEqual(len(processes), 9)
|
||||
self.assertEqual(busy_count, 6)
|
||||
self.assertEqual(
|
||||
set(modules),
|
||||
{
|
||||
"govoplan_connectors.backend.tabular_adapters",
|
||||
"govoplan_templates.backend.rendering",
|
||||
"govoplan_dataflow.backend.backends.reference",
|
||||
},
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"local_composition": {
|
||||
"successful_children": len(processes),
|
||||
"busy_rejections": busy_count,
|
||||
"maximum_unreaped_children": maximum_unreaped,
|
||||
"all_children_reaped": True,
|
||||
"seconds": round(time.monotonic() - started, 3),
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -96,6 +96,18 @@ class PlatformInterfaceInventoryTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "tracking_issue"):
|
||||
inventory._load_endpoint_declarations(path)
|
||||
|
||||
def test_bounded_workflow_read_apis_have_explicit_headless_declarations(self) -> None:
|
||||
declarations = inventory._load_endpoint_declarations(inventory.DEFAULT_ENDPOINT_DECLARATIONS)
|
||||
for path in (
|
||||
"/workflow/instances/summaries", "/workflow/instances/{}/summary",
|
||||
"/workflow/instances/{}/steps", "/workflow/instances/{}/events",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
entry = declarations[("govoplan-workflow-engine", "GET", path)]
|
||||
self.assertEqual("intentionally_headless", entry["category"])
|
||||
self.assertIn("current-authorized", entry["rationale"])
|
||||
self.assertIn("workflow.instance-history", entry["rationale"])
|
||||
|
||||
def test_inventory_reports_unclassified_and_stale_endpoint_declarations(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from contextlib import redirect_stdout
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import shutil
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools/release"))
|
||||
|
||||
from govoplan_release import meta_preparation # noqa: E402
|
||||
from govoplan_release.git_state import collect_repository_snapshot # noqa: E402
|
||||
from govoplan_release.meta_preparation import ( # noqa: E402
|
||||
MetaPreparationError,
|
||||
prepare_developer_meta_package,
|
||||
)
|
||||
from govoplan_release.model import RepositorySpec # noqa: E402
|
||||
from govoplan_release.selective_planner import build_selective_release_plan # noqa: E402
|
||||
from govoplan_release.version_metadata import ( # noqa: E402
|
||||
VersionMetadataError,
|
||||
apply_version_metadata_mutations,
|
||||
version_metadata_mutations,
|
||||
)
|
||||
import test_release_meta_source_tag as meta_fixture # noqa: E402
|
||||
from test_release_plan_guidance import dashboard # noqa: E402
|
||||
from test_release_repository_tag import create_release_repo, git, git_text # noqa: E402
|
||||
|
||||
|
||||
class MetaPreparationTests(unittest.TestCase):
|
||||
synchronize = meta_fixture.MetaSourceTagTests.synchronize
|
||||
|
||||
def setUp(self):
|
||||
meta_fixture.MetaSourceTagTests.setUp(self)
|
||||
self.operator = self.root / "operator"
|
||||
self.generator = (
|
||||
self.operator / "tools/release/generate-developer-meta-package.py"
|
||||
)
|
||||
self.generator.parent.mkdir(parents=True)
|
||||
shutil.copyfile(
|
||||
ROOT / "tools/release/generate-developer-meta-package.py", self.generator
|
||||
)
|
||||
self.enterContext(patch.object(meta_preparation, "META_ROOT", self.operator))
|
||||
|
||||
def prepare_core(self):
|
||||
apply_version_metadata_mutations(self.core, target_version="0.1.11")
|
||||
git(self.core, "add", ".")
|
||||
git(self.core, "commit", "-m", "Prepared synthetic Core target")
|
||||
|
||||
def preview(self, **kwargs):
|
||||
return prepare_developer_meta_package(
|
||||
repo_path=self.meta, target_version="0.1.11", **kwargs
|
||||
)
|
||||
|
||||
def apply(self, preview):
|
||||
return self.preview(
|
||||
apply=True, expected_receipt=preview["receipt"], confirm_out_of_run=True
|
||||
)
|
||||
|
||||
def test_full_canonical_preview_apply_and_shared_mutation_discovery(self):
|
||||
extra, remote = create_release_repo(
|
||||
root=self.root,
|
||||
workspace=self.workspace,
|
||||
name="govoplan-workflow-engine",
|
||||
version="0.2.3",
|
||||
)
|
||||
self.specs.append(
|
||||
{
|
||||
"name": extra.name,
|
||||
"path": extra.name,
|
||||
"category": "module",
|
||||
"subtype": "",
|
||||
"remote": str(remote),
|
||||
}
|
||||
)
|
||||
self.registry.write_text(json.dumps({"repositories": self.specs}))
|
||||
self.prepare_core()
|
||||
before = self.package.read_bytes()
|
||||
preview = self.preview()
|
||||
self.assertEqual("planned", preview["status"])
|
||||
self.assertEqual(before, self.package.read_bytes())
|
||||
mutations = version_metadata_mutations(self.meta, target_version="0.1.11")
|
||||
self.assertEqual([meta_preparation.PACKAGE], [item.path for item in mutations])
|
||||
self.assertIn(b"govoplan-workflow-engine==0.2.3", mutations[0].after)
|
||||
self.assertIn(b"govoplan-core==0.1.11", mutations[0].after)
|
||||
result = self.apply(preview)
|
||||
self.assertEqual("prepared", result["status"])
|
||||
self.assertEqual(mutations[0].after, self.package.read_bytes())
|
||||
self.assertEqual(
|
||||
self.render(workspace=self.workspace, requirements=self.requirements),
|
||||
self.package.read_text(),
|
||||
)
|
||||
self.assertEqual(
|
||||
f"M {meta_preparation.PACKAGE}",
|
||||
git_text(self.meta, "status", "--porcelain"),
|
||||
)
|
||||
self.assertFalse(git_text(self.meta, "tag", "--list"))
|
||||
|
||||
def test_core_target_must_already_be_prepared(self):
|
||||
before = self.package.read_bytes()
|
||||
with self.assertRaisesRegex(MetaPreparationError, "Prepare and commit Core"):
|
||||
self.preview()
|
||||
self.assertEqual(before, self.package.read_bytes())
|
||||
|
||||
def test_changed_requirements_receipt_blocks_before_any_output(self):
|
||||
self.prepare_core()
|
||||
preview = self.preview()
|
||||
before = self.package.read_bytes()
|
||||
self.requirements.write_text(
|
||||
self.requirements.read_text() + "# reviewed different inputs\n"
|
||||
)
|
||||
git(self.meta, "add", ".")
|
||||
git(self.meta, "commit", "-m", "Changed synthetic requirements")
|
||||
with self.assertRaisesRegex(MetaPreparationError, "changed since"):
|
||||
self.apply(preview)
|
||||
self.assertEqual(before, self.package.read_bytes())
|
||||
|
||||
def test_source_change_immediately_before_effect_is_rechecked(self):
|
||||
self.prepare_core()
|
||||
preview = self.preview()
|
||||
before = self.package.read_bytes()
|
||||
original = meta_preparation.preview_meta_mutation
|
||||
|
||||
def changed(**kwargs):
|
||||
result = original(**kwargs)
|
||||
self.requirements.write_text(
|
||||
self.requirements.read_text() + "# concurrent change\n"
|
||||
)
|
||||
git(self.meta, "add", ".")
|
||||
git(self.meta, "commit", "-m", "Concurrent synthetic change")
|
||||
return result
|
||||
|
||||
with patch.object(
|
||||
meta_preparation, "preview_meta_mutation", side_effect=changed
|
||||
):
|
||||
with self.assertRaisesRegex(MetaPreparationError, "changed before"):
|
||||
self.apply(preview)
|
||||
self.assertEqual(before, self.package.read_bytes())
|
||||
|
||||
def test_core_full_package_and_operator_generator_are_receipt_bound(self):
|
||||
self.prepare_core()
|
||||
for path, repository in (
|
||||
(self.core / "pyproject.toml", self.core),
|
||||
(self.access / "pyproject.toml", self.access),
|
||||
(self.generator, None),
|
||||
):
|
||||
with self.subTest(input=path.name, repo=str(repository)):
|
||||
preview = self.preview()
|
||||
before = self.package.read_bytes()
|
||||
path.write_text(path.read_text() + "\n# changed frozen input\n")
|
||||
if repository is not None:
|
||||
git(repository, "add", ".")
|
||||
git(
|
||||
repository,
|
||||
"commit",
|
||||
"-m",
|
||||
"Changed synthetic composition input",
|
||||
)
|
||||
with self.assertRaisesRegex(MetaPreparationError, "changed since"):
|
||||
self.apply(preview)
|
||||
self.assertEqual(before, self.package.read_bytes())
|
||||
|
||||
def test_post_write_source_change_is_reported_without_retry_or_rollback(self):
|
||||
from govoplan_release import version_metadata
|
||||
|
||||
self.prepare_core()
|
||||
preview = self.preview()
|
||||
original = version_metadata._atomic_write
|
||||
|
||||
def changed(path, payload):
|
||||
original(path, payload)
|
||||
self.requirements.write_text(
|
||||
self.requirements.read_text() + "# concurrent after write\n"
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
version_metadata, "_atomic_write", side_effect=changed
|
||||
) as writer:
|
||||
with self.assertRaisesRegex(
|
||||
meta_preparation.MetaPreparationAmbiguous, "write/post-check failed"
|
||||
):
|
||||
self.apply(preview)
|
||||
self.assertEqual(1, writer.call_count)
|
||||
self.assertIn('version = "0.1.11"', self.package.read_text())
|
||||
self.assertIn("# concurrent after write", self.requirements.read_text())
|
||||
|
||||
def test_write_failure_after_replace_requires_reconciliation(self):
|
||||
from govoplan_release import version_metadata
|
||||
|
||||
self.prepare_core()
|
||||
preview = self.preview()
|
||||
original = version_metadata._atomic_write
|
||||
|
||||
def partial(path, payload):
|
||||
original(path, payload)
|
||||
raise OSError("Synthetic directory fsync failure after replacement")
|
||||
|
||||
with patch.object(version_metadata, "_atomic_write", side_effect=partial) as writer:
|
||||
with self.assertRaisesRegex(meta_preparation.MetaPreparationAmbiguous, "may have been written"):
|
||||
self.apply(preview)
|
||||
self.assertEqual(1, writer.call_count)
|
||||
self.assertIn('version = "0.1.11"', self.package.read_text())
|
||||
|
||||
def test_cli_requires_reviewed_receipt_and_explicit_out_of_run_confirmation(self):
|
||||
self.prepare_core()
|
||||
main = runpy.run_path(
|
||||
str(ROOT / "tools/release/prepare-developer-meta-package.py")
|
||||
)["main"]
|
||||
arguments = [
|
||||
"prepare-developer-meta-package.py",
|
||||
"--workspace",
|
||||
str(self.workspace),
|
||||
"--target-version",
|
||||
"0.1.11",
|
||||
]
|
||||
output = io.StringIO()
|
||||
with patch.object(sys, "argv", arguments), redirect_stdout(output):
|
||||
self.assertEqual(0, main())
|
||||
preview = self.root / "meta-preview.json"
|
||||
preview.write_text(output.getvalue())
|
||||
with (
|
||||
patch.object(sys, "argv", [*arguments, "--apply"]),
|
||||
redirect_stdout(io.StringIO()),
|
||||
):
|
||||
self.assertEqual(1, main())
|
||||
with (
|
||||
patch.object(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
*arguments,
|
||||
"--apply",
|
||||
"--receipt",
|
||||
str(preview),
|
||||
"--confirm-out-of-run",
|
||||
],
|
||||
),
|
||||
redirect_stdout(io.StringIO()),
|
||||
):
|
||||
self.assertEqual(0, main())
|
||||
|
||||
def test_unknown_full_input_and_unsafe_operator_tooling_fail_closed(self):
|
||||
self.prepare_core()
|
||||
unknown = self.workspace / "govoplan-unknown/pyproject.toml"
|
||||
unknown.parent.mkdir()
|
||||
unknown.write_text('[project]\nname="govoplan-unknown"\nversion="1.0.0"\n')
|
||||
with self.assertRaisesRegex(MetaPreparationError, "unregistered"):
|
||||
self.preview()
|
||||
unknown.unlink()
|
||||
self.generator.chmod(0o666)
|
||||
with self.assertRaisesRegex(MetaPreparationError, "owned, bounded regular"):
|
||||
self.preview()
|
||||
|
||||
def test_wrong_nested_identity_and_existing_immutable_tag_fail_closed(self):
|
||||
self.prepare_core()
|
||||
original = self.package.read_text()
|
||||
self.package.write_text(
|
||||
original.replace('name = "govoplan"', 'name = "not-govoplan"')
|
||||
)
|
||||
git(self.meta, "add", ".")
|
||||
git(self.meta, "commit", "-m", "Wrong synthetic package identity")
|
||||
with self.assertRaisesRegex(MetaPreparationError, "identity"):
|
||||
self.preview()
|
||||
self.package.write_text(original)
|
||||
git(self.meta, "add", ".")
|
||||
git(self.meta, "commit", "-m", "Restore synthetic package identity")
|
||||
git(self.meta, "tag", "-a", "v0.1.11", "-m", "Immutable target")
|
||||
with self.assertRaisesRegex(MetaPreparationError, "target Meta tag"):
|
||||
self.preview()
|
||||
|
||||
def test_no_generic_durable_self_mutation_or_running_tooling_target(self):
|
||||
self.prepare_core()
|
||||
preview = self.preview()
|
||||
with self.assertRaisesRegex(VersionMetadataError, "outside durable runs"):
|
||||
apply_version_metadata_mutations(self.meta, target_version="0.1.11")
|
||||
with self.assertRaisesRegex(MetaPreparationError, "confirm"):
|
||||
self.preview(apply=True, expected_receipt=preview["receipt"])
|
||||
with patch.object(meta_preparation, "META_ROOT", self.meta):
|
||||
with self.assertRaisesRegex(MetaPreparationError, "running operator"):
|
||||
self.preview()
|
||||
|
||||
def test_next_version_plan_is_actionable_core_first_without_meta_executor(self):
|
||||
snapshots = tuple(
|
||||
collect_repository_snapshot(
|
||||
RepositorySpec(**spec),
|
||||
workspace_root=self.workspace,
|
||||
target_tag="v0.1.11",
|
||||
)
|
||||
for spec in self.specs[:2]
|
||||
)
|
||||
source = replace(
|
||||
dashboard(workspace=self.workspace, version=self.version),
|
||||
repositories=snapshots,
|
||||
)
|
||||
plan = build_selective_release_plan(
|
||||
source,
|
||||
selected_repos=("govoplan", "govoplan-core"),
|
||||
target_version="0.1.11",
|
||||
)
|
||||
self.assertEqual(
|
||||
["govoplan-core", "govoplan"], [unit.repo for unit in plan.units]
|
||||
)
|
||||
findings = [
|
||||
finding for finding in plan.gate_findings if finding.repo == "govoplan"
|
||||
]
|
||||
self.assertEqual(
|
||||
["developer_meta_core_preparation_required"],
|
||||
[finding.code for finding in findings],
|
||||
)
|
||||
self.assertIn("prepare-developer-meta-package.py", findings[0].remediation)
|
||||
meta_steps = [step for step in plan.dry_run_steps if step.repo == "govoplan"]
|
||||
self.assertEqual(
|
||||
["govoplan:prepare-support", "govoplan:publish-support"],
|
||||
[step.id for step in meta_steps],
|
||||
)
|
||||
self.assertTrue(all(step.status == "needs-executor" for step in meta_steps))
|
||||
self.prepare_core()
|
||||
prepared = build_selective_release_plan(
|
||||
source, selected_repos=("govoplan",), target_version="0.1.11"
|
||||
)
|
||||
self.assertEqual("developer_meta_out_of_run", prepared.gate_findings[0].code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,569 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools/release"))
|
||||
|
||||
from govoplan_release import source_tag_batch as meta_source_tag, workspace # noqa: E402
|
||||
from govoplan_release.git_state import collect_versions # noqa: E402
|
||||
from govoplan_release.model import RepositorySnapshot, RepositorySpec, VersionSnapshot # noqa: E402
|
||||
from govoplan_release.repository_tag import tag_repositories # noqa: E402
|
||||
from govoplan_release.selective_planner import build_unit # noqa: E402
|
||||
from govoplan_release.version_alignment import repository_version_issues # noqa: E402
|
||||
from test_release_repository_tag import ( # noqa: E402
|
||||
add_scoped_workflow_manifest,
|
||||
create_release_repo,
|
||||
git,
|
||||
git_text,
|
||||
ref_exists,
|
||||
)
|
||||
|
||||
|
||||
class MetaSourceTagTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = self.enterContext(
|
||||
tempfile.TemporaryDirectory(prefix="meta-release-tests-")
|
||||
)
|
||||
self.root = Path(self.temporary)
|
||||
self.workspace = self.root / "workspace"
|
||||
self.workspace.mkdir()
|
||||
self.version = "0.1.10"
|
||||
self.core, self.core_remote = create_release_repo(
|
||||
root=self.root,
|
||||
workspace=self.workspace,
|
||||
name="govoplan-core",
|
||||
version=self.version,
|
||||
)
|
||||
self.access, self.access_remote = create_release_repo(
|
||||
root=self.root,
|
||||
workspace=self.workspace,
|
||||
name="govoplan-access",
|
||||
version=self.version,
|
||||
)
|
||||
add_scoped_workflow_manifest(self.access)
|
||||
self.meta = self.workspace / "govoplan"
|
||||
self.meta_remote = self.root / "govoplan.git"
|
||||
git(self.root, "init", "--bare", str(self.meta_remote))
|
||||
git(self.workspace, "init", "-b", "main", str(self.meta))
|
||||
git(self.meta, "config", "user.name", "Meta Release Fixture")
|
||||
git(self.meta, "config", "user.email", "release@example.invalid")
|
||||
self.package = self.meta / "packages/govoplan-meta/pyproject.toml"
|
||||
self.package.parent.mkdir(parents=True)
|
||||
self.requirements = self.meta / "requirements-release.txt"
|
||||
self.requirements.write_text(
|
||||
"../govoplan-core\ngovoplan-access @ git+ssh://git@example.invalid/GovOPlaN/govoplan-access.git@v0.1.10\n"
|
||||
)
|
||||
self.render = runpy.run_path(
|
||||
str(ROOT / "tools/release/generate-developer-meta-package.py")
|
||||
)["render"]
|
||||
self.synchronize()
|
||||
git(self.meta, "add", ".")
|
||||
git(self.meta, "commit", "-m", "Nested developer package")
|
||||
git(self.meta, "remote", "add", "origin", str(self.meta_remote))
|
||||
git(self.meta, "push", "-u", "origin", "main")
|
||||
self.specs = [
|
||||
{
|
||||
"name": name,
|
||||
"category": "system" if subtype else "module",
|
||||
"subtype": subtype,
|
||||
"path": name,
|
||||
"remote": str(remote),
|
||||
}
|
||||
for name, subtype, remote in (
|
||||
("govoplan", "meta", self.meta_remote),
|
||||
("govoplan-core", "kernel", self.core_remote),
|
||||
("govoplan-access", "", self.access_remote),
|
||||
)
|
||||
]
|
||||
self.registry = self.root / "repositories.json"
|
||||
self.registry.write_text(json.dumps({"repositories": self.specs}))
|
||||
self.enterContext(patch.object(workspace, "REPOSITORIES_FILE", self.registry))
|
||||
|
||||
def synchronize(self):
|
||||
self.package.write_text(
|
||||
self.render(workspace=self.workspace, requirements=self.requirements)
|
||||
)
|
||||
|
||||
def commit_meta(self):
|
||||
git(self.meta, "add", ".")
|
||||
git(self.meta, "commit", "-m", "Changed synthetic metadata")
|
||||
|
||||
def tag(
|
||||
self,
|
||||
*,
|
||||
repos=("govoplan", "govoplan-core"),
|
||||
apply=False,
|
||||
push=False,
|
||||
**overrides,
|
||||
):
|
||||
return tag_repositories(
|
||||
repos=repos,
|
||||
repo_versions={repo: self.version for repo in repos},
|
||||
workspace_root=self.workspace,
|
||||
apply=apply,
|
||||
push=push,
|
||||
**overrides,
|
||||
)
|
||||
|
||||
def assert_no_tags(self):
|
||||
for repo in (
|
||||
self.meta,
|
||||
self.meta_remote,
|
||||
self.core,
|
||||
self.core_remote,
|
||||
self.access,
|
||||
self.access_remote,
|
||||
):
|
||||
self.assertFalse(ref_exists(repo, "refs/tags/v0.1.10"), str(repo))
|
||||
|
||||
def test_explicit_nested_version_collection_and_alignment_without_root_package(
|
||||
self,
|
||||
):
|
||||
versions = collect_versions(self.meta)
|
||||
self.assertIsNone(versions.pyproject)
|
||||
self.assertEqual(self.version, versions.developer_meta)
|
||||
self.assertEqual(self.version, versions.primary)
|
||||
self.assertFalse((self.meta / "pyproject.toml").exists())
|
||||
self.assertEqual(
|
||||
(), repository_version_issues(self.meta, expected_version=self.version)
|
||||
)
|
||||
mismatch = repository_version_issues(self.meta, expected_version="0.1.11")
|
||||
self.assertTrue(
|
||||
any(
|
||||
issue.source == "packages/govoplan-meta/pyproject.toml"
|
||||
for issue in mismatch
|
||||
)
|
||||
)
|
||||
|
||||
def test_planner_and_console_display_the_explicit_nested_version(self):
|
||||
snapshot = RepositorySnapshot(
|
||||
spec=RepositorySpec(**self.specs[0]),
|
||||
absolute_path=str(self.meta),
|
||||
exists=True,
|
||||
is_git=True,
|
||||
has_head=True,
|
||||
branch="main",
|
||||
versions=VersionSnapshot(developer_meta=self.version),
|
||||
)
|
||||
unit = build_unit(snapshot, target_version=None, contracts=None)
|
||||
self.assertEqual(self.version, unit.current_version)
|
||||
self.assertEqual(self.version, unit.target_version)
|
||||
html = (ROOT / "tools/release/webui/index.html").read_text()
|
||||
self.assertIn(
|
||||
"if (versions.developer_meta) return versions.developer_meta;", html
|
||||
)
|
||||
drift = RepositorySnapshot(
|
||||
spec=snapshot.spec,
|
||||
absolute_path=str(self.meta),
|
||||
exists=True,
|
||||
is_git=True,
|
||||
has_head=True,
|
||||
branch="main",
|
||||
versions=VersionSnapshot(pyproject="0.1.9", developer_meta=self.version),
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"version metadata is not aligned" in item
|
||||
for item in build_unit(
|
||||
drift, target_version=self.version, contracts=None
|
||||
).blockers
|
||||
)
|
||||
)
|
||||
|
||||
def test_unknown_nested_package_and_missing_or_wrong_meta_identity_fail_closed(
|
||||
self,
|
||||
):
|
||||
unknown = self.workspace / "unknown"
|
||||
nested = unknown / "packages/govoplan-meta/pyproject.toml"
|
||||
nested.parent.mkdir(parents=True)
|
||||
nested.write_text(self.package.read_text())
|
||||
self.assertIsNone(collect_versions(unknown).primary)
|
||||
self.assertIn(
|
||||
"no version metadata",
|
||||
repository_version_issues(unknown, expected_version=self.version)[
|
||||
0
|
||||
].message,
|
||||
)
|
||||
for value in ("", '[project]\nname="not-govoplan"\nversion="0.1.10"\n'):
|
||||
with self.subTest(value=value):
|
||||
self.package.write_text(value)
|
||||
self.assertTrue(
|
||||
repository_version_issues(self.meta, expected_version=self.version)
|
||||
)
|
||||
|
||||
def test_preview_local_tag_and_publish_share_complete_nested_contract(self):
|
||||
preview = self.tag(push=True)
|
||||
self.assertEqual("planned", preview["status"], preview)
|
||||
self.assertEqual(
|
||||
["govoplan-core", "govoplan"],
|
||||
[row["repo"] for row in preview["repositories"]],
|
||||
)
|
||||
self.assertEqual("registered-meta-batch-v1", preview["source_contract"])
|
||||
self.assert_no_tags()
|
||||
local = self.tag(apply=True)
|
||||
self.assertEqual("tagged", local["status"], local)
|
||||
for repo in (self.core, self.meta):
|
||||
self.assertEqual(
|
||||
"tag", git_text(repo, "cat-file", "-t", "refs/tags/v0.1.10")
|
||||
)
|
||||
self.assertFalse(ref_exists(self.meta_remote, "refs/tags/v0.1.10"))
|
||||
published = self.tag(apply=True, push=True)
|
||||
self.assertEqual("published", published["status"], published)
|
||||
for repo, remote in (
|
||||
(self.core, self.core_remote),
|
||||
(self.meta, self.meta_remote),
|
||||
):
|
||||
self.assertEqual(
|
||||
git_text(repo, "rev-parse", "HEAD"),
|
||||
git_text(remote, "rev-parse", "refs/heads/main"),
|
||||
)
|
||||
self.assertEqual(
|
||||
git_text(repo, "rev-parse", "refs/tags/v0.1.10"),
|
||||
git_text(remote, "rev-parse", "refs/tags/v0.1.10"),
|
||||
)
|
||||
again = self.tag(apply=True, push=True)
|
||||
self.assertEqual("published", again["status"], again)
|
||||
|
||||
def test_stale_composition_blocks_whole_batch_before_local_tag_or_push(self):
|
||||
self.package.write_text(
|
||||
self.package.read_text().replace(
|
||||
"govoplan-access==0.1.10", "govoplan-access==0.1.9"
|
||||
)
|
||||
)
|
||||
self.commit_meta()
|
||||
for apply, push in ((False, False), (True, False), (True, True)):
|
||||
result = self.tag(
|
||||
repos=("govoplan-core", "govoplan-access", "govoplan"),
|
||||
apply=apply,
|
||||
push=push,
|
||||
)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_core_outside_batch_requires_matching_existing_and_published_tag(self):
|
||||
self.assertEqual("blocked", self.tag(repos=("govoplan",))["status"])
|
||||
git(self.core, "tag", "-a", "v0.1.10", "-m", "Core release")
|
||||
self.assertEqual("planned", self.tag(repos=("govoplan",))["status"])
|
||||
self.assertEqual("blocked", self.tag(repos=("govoplan",), push=True)["status"])
|
||||
git(self.core, "push", "origin", "refs/tags/v0.1.10")
|
||||
self.assertEqual("planned", self.tag(repos=("govoplan",), push=True)["status"])
|
||||
|
||||
def test_changed_core_version_and_explicit_selected_version_mismatch_block(self):
|
||||
mismatch = tag_repositories(
|
||||
repos=("govoplan", "govoplan-core"),
|
||||
repo_versions={"govoplan": self.version, "govoplan-core": "0.1.11"},
|
||||
workspace_root=self.workspace,
|
||||
apply=True,
|
||||
push=True,
|
||||
)
|
||||
self.assertEqual("blocked", mismatch["status"], mismatch)
|
||||
(self.core / "pyproject.toml").write_text(
|
||||
'[project]\nname="govoplan-core"\nversion="0.1.11"\n'
|
||||
)
|
||||
git(self.core, "add", ".")
|
||||
git(self.core, "commit", "-m", "Core new version")
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_unsafe_origin_on_any_selected_repo_blocks_every_effect(self):
|
||||
for repo in (self.meta, self.core, self.access):
|
||||
with self.subTest(repo=repo.name):
|
||||
git(
|
||||
repo,
|
||||
"config",
|
||||
"remote.origin.pushurl",
|
||||
str(self.root / "unregistered.git"),
|
||||
)
|
||||
result = self.tag(
|
||||
repos=("govoplan-core", "govoplan-access", "govoplan"),
|
||||
apply=True,
|
||||
push=True,
|
||||
)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("registered origin", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
git(repo, "config", "--unset", "remote.origin.pushurl")
|
||||
|
||||
def test_world_writable_nonsticky_parent_blocks_without_changing_permissions(self):
|
||||
original = self.root.stat().st_mode & 0o7777
|
||||
self.root.chmod(0o777)
|
||||
try:
|
||||
for apply in (False, True):
|
||||
result = self.tag(apply=apply, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn(
|
||||
"group/world writable", result["repositories"][0]["detail"]
|
||||
)
|
||||
self.assertEqual(0o777, self.root.stat().st_mode & 0o7777)
|
||||
self.assert_no_tags()
|
||||
finally:
|
||||
self.root.chmod(original)
|
||||
|
||||
def test_wrong_metadata_owner_blocks_before_remote_lookup(self):
|
||||
config = self.meta / ".git/config"
|
||||
original = Path.lstat
|
||||
|
||||
def wrong_owner(path, *args, **kwargs):
|
||||
observed = original(path, *args, **kwargs)
|
||||
if path == config:
|
||||
fields = list(observed)
|
||||
fields[4] = os.geteuid() + 1
|
||||
return os.stat_result(fields)
|
||||
return observed
|
||||
|
||||
with (
|
||||
patch.object(Path, "lstat", new=wrong_owner),
|
||||
patch.object(
|
||||
meta_source_tag,
|
||||
"registered_source_origin_issues",
|
||||
side_effect=AssertionError("must validate ownership before Git"),
|
||||
),
|
||||
):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("current operator", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_hidden_index_flags_cannot_disguise_modified_release_metadata(self):
|
||||
for flag, undo in (
|
||||
("--assume-unchanged", "--no-assume-unchanged"),
|
||||
("--skip-worktree", "--no-skip-worktree"),
|
||||
):
|
||||
for repo, relative in (
|
||||
(self.meta, "packages/govoplan-meta/pyproject.toml"),
|
||||
(self.core, "pyproject.toml"),
|
||||
):
|
||||
with self.subTest(flag=flag, repo=repo.name):
|
||||
target = repo / relative
|
||||
original = target.read_text()
|
||||
git(repo, "update-index", flag, relative)
|
||||
target.write_text(
|
||||
original
|
||||
+ "\n# Hidden working-tree input differs from frozen HEAD\n"
|
||||
)
|
||||
try:
|
||||
self.assertEqual("", git_text(repo, "status", "--porcelain"))
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn(
|
||||
"index entries", result["repositories"][0]["detail"]
|
||||
)
|
||||
self.assert_no_tags()
|
||||
finally:
|
||||
target.write_text(original)
|
||||
git(repo, "update-index", undo, relative)
|
||||
|
||||
def test_read_only_git_target_is_not_repaired_or_tagged(self):
|
||||
metadata = self.meta / ".git"
|
||||
original = metadata.stat().st_mode & 0o7777
|
||||
metadata.chmod(0o500)
|
||||
try:
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertEqual(0o500, metadata.stat().st_mode & 0o7777)
|
||||
self.assert_no_tags()
|
||||
finally:
|
||||
metadata.chmod(original)
|
||||
|
||||
def test_git_object_alternates_are_rejected_before_remote_lookup(self):
|
||||
(self.meta / ".git/objects/info/alternates").write_text(
|
||||
str(self.root / "outside-objects") + "\n"
|
||||
)
|
||||
with patch.object(
|
||||
meta_source_tag,
|
||||
"registered_source_origin_issues",
|
||||
side_effect=AssertionError("must reject alternates before Git"),
|
||||
):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("alternates", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_owned_worktree_metadata_inside_private_workspace_is_supported(self):
|
||||
main_checkout = self.workspace / "meta-main-storage"
|
||||
self.meta.rename(main_checkout)
|
||||
git(main_checkout, "worktree", "add", "--force", str(self.meta), "main")
|
||||
self.assertTrue((self.meta / ".git").is_file())
|
||||
result = self.tag(apply=True)
|
||||
self.assertEqual("tagged", result["status"], result)
|
||||
filesystem = result["source_receipts"]["govoplan"]["filesystem"]
|
||||
self.assertEqual(
|
||||
str(main_checkout / ".git"), filesystem["git_common_directory"][0]
|
||||
)
|
||||
|
||||
def test_git_directory_replacement_with_same_head_changes_frozen_receipt(self):
|
||||
preview = meta_source_tag._preview_repositories
|
||||
|
||||
def swapped_git_directory(**kwargs):
|
||||
result = preview(**kwargs)
|
||||
original = self.meta / ".git"
|
||||
backup = self.root / "original-meta-git"
|
||||
original.rename(backup)
|
||||
shutil.copytree(backup, original)
|
||||
return result
|
||||
|
||||
with patch.object(
|
||||
meta_source_tag,
|
||||
"_preview_repositories",
|
||||
side_effect=swapped_git_directory,
|
||||
):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("receipt changed", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_non_main_and_divergent_live_main_fail_even_with_stale_tracking(self):
|
||||
git(self.meta, "switch", "-c", "feature")
|
||||
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||
git(self.meta, "switch", "main")
|
||||
clone = self.root / "other-writer"
|
||||
git(self.root, "clone", "--branch", "main", str(self.meta_remote), str(clone))
|
||||
git(clone, "config", "user.name", "Other synthetic writer")
|
||||
git(clone, "config", "user.email", "other@example.invalid")
|
||||
(clone / "other.txt").write_text("remote divergence\n")
|
||||
git(clone, "add", ".")
|
||||
git(clone, "commit", "-m", "Remote main advanced")
|
||||
git(clone, "push", "origin", "main")
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("live origin/main", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_symlink_checkout_is_not_a_registered_source(self):
|
||||
original = self.workspace / "moved-meta"
|
||||
self.meta.rename(original)
|
||||
self.meta.symlink_to(original, target_is_directory=True)
|
||||
result = self.tag(apply=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("symlink", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_lightweight_and_conflicting_annotated_tags_block(self):
|
||||
git(self.meta, "tag", "v0.1.10")
|
||||
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||
git(self.meta, "tag", "-d", "v0.1.10")
|
||||
git(self.meta, "tag", "-a", "v0.1.10", "-m", "First annotation")
|
||||
git(self.meta, "push", "origin", "refs/tags/v0.1.10")
|
||||
git(self.meta, "tag", "-d", "v0.1.10")
|
||||
git(self.meta, "tag", "-a", "v0.1.10", "-m", "Different annotation")
|
||||
self.assertEqual("blocked", self.tag(apply=True, push=True)["status"])
|
||||
self.assertFalse(ref_exists(self.core, "refs/tags/v0.1.10"))
|
||||
|
||||
def test_meta_source_receipt_changed_after_preflight_blocks_before_first_effect(
|
||||
self,
|
||||
):
|
||||
preview = meta_source_tag._preview_repositories
|
||||
|
||||
def changed_after_preflight(**kwargs):
|
||||
result = preview(**kwargs)
|
||||
(self.meta / "new-review.txt").write_text("changed after preflight\n")
|
||||
self.commit_meta()
|
||||
return result
|
||||
|
||||
with patch.object(
|
||||
meta_source_tag,
|
||||
"_preview_repositories",
|
||||
side_effect=changed_after_preflight,
|
||||
):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("receipt changed", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_fabricated_push_success_without_remote_receipt_fails_and_stops_batch(self):
|
||||
original = meta_source_tag.run
|
||||
|
||||
def run(command, **kwargs):
|
||||
if command[:3] == ("git", "push", "--atomic"):
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
return original(command, **kwargs)
|
||||
|
||||
with patch.object(meta_source_tag, "run", side_effect=run):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("partial", result["status"], result)
|
||||
self.assertEqual("failed", result["repositories"][0]["status"])
|
||||
self.assertEqual("skipped", result["repositories"][1]["status"])
|
||||
self.assertFalse(ref_exists(self.meta, "refs/tags/v0.1.10"))
|
||||
self.assertFalse(ref_exists(self.core_remote, "refs/tags/v0.1.10"))
|
||||
|
||||
def test_remote_tag_without_expected_main_receipt_is_not_success(self):
|
||||
(self.core / "reviewed-change.txt").write_text("release source changes\n")
|
||||
git(self.core, "add", ".")
|
||||
git(self.core, "commit", "-m", "Advance reviewed Core source")
|
||||
original = meta_source_tag.run
|
||||
pushes = []
|
||||
|
||||
def tag_only(command, **kwargs):
|
||||
if command[:3] == ("git", "push", "--atomic"):
|
||||
pushes.append(command)
|
||||
# Simulate a defective transport that claims atomic success,
|
||||
# while publishing only the exact expected annotation object.
|
||||
return original(("git", "push", "origin", command[-1]), **kwargs)
|
||||
return original(command, **kwargs)
|
||||
|
||||
with patch.object(meta_source_tag, "run", side_effect=tag_only):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("partial", result["status"], result)
|
||||
self.assertEqual(1, len(pushes))
|
||||
self.assertIn("receipt changed", result["repositories"][0]["detail"])
|
||||
self.assertEqual(
|
||||
git_text(self.core, "rev-parse", "refs/tags/v0.1.10"),
|
||||
git_text(self.core_remote, "rev-parse", "refs/tags/v0.1.10"),
|
||||
)
|
||||
self.assertNotEqual(
|
||||
git_text(self.core, "rev-parse", "HEAD"),
|
||||
git_text(self.core_remote, "rev-parse", "refs/heads/main"),
|
||||
)
|
||||
self.assertFalse(ref_exists(self.meta, "refs/tags/v0.1.10"))
|
||||
|
||||
def test_meta_receipt_is_rechecked_after_an_earlier_successful_publication(self):
|
||||
original = meta_source_tag.run
|
||||
|
||||
def changed_after_core(command, **kwargs):
|
||||
result = original(command, **kwargs)
|
||||
if (
|
||||
command[:3] == ("git", "push", "--atomic")
|
||||
and kwargs["cwd"] == self.core
|
||||
):
|
||||
(self.meta / "changed-review.txt").write_text(
|
||||
"new Meta source after Core publication\n"
|
||||
)
|
||||
self.commit_meta()
|
||||
return result
|
||||
|
||||
with patch.object(meta_source_tag, "run", side_effect=changed_after_core):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("partial", result["status"], result)
|
||||
self.assertTrue(ref_exists(self.core_remote, "refs/tags/v0.1.10"))
|
||||
self.assertFalse(ref_exists(self.meta, "refs/tags/v0.1.10"))
|
||||
self.assertEqual("skipped", result["repositories"][1]["status"])
|
||||
|
||||
def test_unknown_selected_repository_cannot_use_meta_support_exception(self):
|
||||
result = self.tag(repos=("govoplan", "unknown"), apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("not registered", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_selected_checkout_generator_is_never_executed(self):
|
||||
malicious = self.meta / "tools/release/generate-developer-meta-package.py"
|
||||
malicious.parent.mkdir(parents=True)
|
||||
malicious.write_text(
|
||||
'raise RuntimeError("selected checkout must not execute")\n'
|
||||
)
|
||||
self.commit_meta()
|
||||
self.assertEqual("planned", self.tag()["status"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -17,6 +18,7 @@ if str(RELEASE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(RELEASE_ROOT))
|
||||
|
||||
from govoplan_release.repository_tag import tag_repositories # noqa: E402
|
||||
from govoplan_release import workspace as release_workspace # noqa: E402
|
||||
from server.app import create_app # noqa: E402
|
||||
|
||||
|
||||
@@ -39,6 +41,14 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
||||
version="0.1.10",
|
||||
)
|
||||
add_scoped_workflow_manifest(self.manifest_repo)
|
||||
# The operator's test catalog explicitly registers known synthetic
|
||||
# endpoints; production trust checks are not patched or bypassed.
|
||||
self.registry = self.root / "registered-test-repositories.json"
|
||||
self.registered = json.loads((META_ROOT / "repositories.json").read_text())
|
||||
for spec in self.registered["repositories"]:
|
||||
spec["remote"] = str(self.root / f"{spec['name']}.git")
|
||||
self.registry.write_text(json.dumps(self.registered))
|
||||
self.enterContext(patch.object(release_workspace, "REPOSITORIES_FILE", self.registry))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
@@ -128,6 +138,10 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
||||
name="govoplan-core",
|
||||
version="0.1.10",
|
||||
)
|
||||
for spec in self.registered["repositories"]:
|
||||
if spec["name"] == "govoplan-core":
|
||||
spec["remote"] = str(remote_root / "govoplan-core.git")
|
||||
self.registry.write_text(json.dumps(self.registered))
|
||||
|
||||
result = tag_repositories(
|
||||
repos=("govoplan-core",),
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools/release"))
|
||||
|
||||
from govoplan_release import source_tag_batch # noqa: E402
|
||||
from govoplan_release.repository_tag import tag_repositories # noqa: E402
|
||||
import test_release_meta_source_tag as meta_fixture # noqa: E402
|
||||
import test_release_repository_tag as release_fixture # noqa: E402
|
||||
from test_release_repository_tag import git, git_text, ref_exists # noqa: E402
|
||||
|
||||
|
||||
class RegisteredSourceTagBatchTests(unittest.TestCase):
|
||||
setUp = meta_fixture.MetaSourceTagTests.setUp
|
||||
synchronize = meta_fixture.MetaSourceTagTests.synchronize
|
||||
assert_no_tags = meta_fixture.MetaSourceTagTests.assert_no_tags
|
||||
|
||||
def tag(self, *, repos=("govoplan-access",), apply=False, push=False):
|
||||
return tag_repositories(
|
||||
repos=repos,
|
||||
repo_versions={repo: self.version for repo in repos},
|
||||
workspace_root=self.workspace,
|
||||
apply=apply,
|
||||
push=push,
|
||||
)
|
||||
|
||||
def test_python_only_module_publishes_without_meta_or_core_checkout(self):
|
||||
self.meta.rename(self.root / "unused-meta")
|
||||
self.core.rename(self.root / "unused-core")
|
||||
preview = self.tag(push=True)
|
||||
self.assertEqual("planned", preview["status"], preview)
|
||||
self.assertEqual("registered-source-batch-v1", preview["source_contract"])
|
||||
self.assertEqual({}, preview["bundle_input_receipts"])
|
||||
self.assertFalse(ref_exists(self.access, "refs/tags/v0.1.10"))
|
||||
published = self.tag(apply=True, push=True)
|
||||
self.assertEqual("published", published["status"], published)
|
||||
self.assertEqual(["govoplan-access"], list(published["source_receipts"]))
|
||||
self.assertEqual(
|
||||
git_text(self.access, "rev-parse", "HEAD"),
|
||||
git_text(self.access_remote, "rev-parse", "refs/heads/main"),
|
||||
)
|
||||
|
||||
def test_core_only_batch_has_no_meta_composition_requirement(self):
|
||||
self.meta.rename(self.root / "unused-meta")
|
||||
result = self.tag(repos=("govoplan-core",), apply=True, push=True)
|
||||
self.assertEqual("published", result["status"], result)
|
||||
self.assertEqual(["govoplan-core"], list(result["source_receipts"]))
|
||||
|
||||
def test_backend_only_publication_never_reads_irrelevant_unsafe_core_json(self):
|
||||
webui = self.core / "webui"
|
||||
webui.mkdir()
|
||||
(webui / "package.release.json").write_text("invalid unselected Core JSON")
|
||||
(webui / "package-lock.release.json").symlink_to(
|
||||
self.root / "not-a-core-release-lock"
|
||||
)
|
||||
from govoplan_release import version_alignment
|
||||
|
||||
original = version_alignment._json_object
|
||||
|
||||
def read(path):
|
||||
if path.is_relative_to(webui):
|
||||
raise AssertionError(
|
||||
"backend-only publication must not read unrelated Core JSON"
|
||||
)
|
||||
return original(path)
|
||||
|
||||
with patch.object(version_alignment, "_json_object", side_effect=read):
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("published", result["status"], result)
|
||||
self.assertEqual({}, result["bundle_input_receipts"])
|
||||
|
||||
def test_source_origin_parent_and_read_only_target_guards_apply_without_meta(self):
|
||||
for problem in ("origin", "parent", "read_only"):
|
||||
with self.subTest(problem=problem):
|
||||
parent_mode = self.root.stat().st_mode & 0o7777
|
||||
git_directory = self.access / ".git"
|
||||
git_mode = git_directory.stat().st_mode & 0o7777
|
||||
if problem == "origin":
|
||||
git(
|
||||
self.access,
|
||||
"config",
|
||||
"remote.origin.pushurl",
|
||||
str(self.root / "unknown.git"),
|
||||
)
|
||||
elif problem == "parent":
|
||||
self.root.chmod(0o777)
|
||||
else:
|
||||
git_directory.chmod(0o500)
|
||||
try:
|
||||
for apply in (False, True):
|
||||
result = self.tag(apply=apply, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assert_no_tags()
|
||||
finally:
|
||||
self.root.chmod(parent_mode)
|
||||
git_directory.chmod(git_mode)
|
||||
if problem == "origin":
|
||||
git(self.access, "config", "--unset", "remote.origin.pushurl")
|
||||
|
||||
def test_wrong_owner_symlink_and_hidden_index_are_rejected_without_meta(self):
|
||||
config = self.access / ".git/config"
|
||||
original_stat = Path.lstat
|
||||
|
||||
def wrong_owner(path, *args, **kwargs):
|
||||
observed = original_stat(path, *args, **kwargs)
|
||||
if path == config:
|
||||
fields = list(observed)
|
||||
fields[4] = os.geteuid() + 1
|
||||
return os.stat_result(fields)
|
||||
return observed
|
||||
|
||||
with patch.object(Path, "lstat", new=wrong_owner):
|
||||
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||
moved = self.root / "moved-access"
|
||||
self.access.rename(moved)
|
||||
self.access.symlink_to(moved, target_is_directory=True)
|
||||
try:
|
||||
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||
finally:
|
||||
self.access.unlink()
|
||||
moved.rename(self.access)
|
||||
for flag, undo in (
|
||||
("--assume-unchanged", "--no-assume-unchanged"),
|
||||
("--skip-worktree", "--no-skip-worktree"),
|
||||
):
|
||||
with self.subTest(flag=flag):
|
||||
git(self.access, "update-index", flag, "pyproject.toml")
|
||||
try:
|
||||
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||
self.assert_no_tags()
|
||||
finally:
|
||||
git(self.access, "update-index", undo, "pyproject.toml")
|
||||
|
||||
def test_live_remote_divergence_blocks_without_cached_tracking_update(self):
|
||||
clone = self.root / "other-access-writer"
|
||||
git(self.root, "clone", "--branch", "main", str(self.access_remote), str(clone))
|
||||
git(clone, "config", "user.name", "Synthetic writer")
|
||||
git(clone, "config", "user.email", "writer@example.invalid")
|
||||
(clone / "advance.txt").write_text("new remote main\n")
|
||||
git(clone, "add", ".")
|
||||
git(clone, "commit", "-m", "Advance remote without updating original tracking")
|
||||
git(clone, "push", "origin", "main")
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("live origin/main", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_ignored_selected_version_metadata_cannot_supply_an_untagged_artifact(self):
|
||||
project = self.access / "pyproject.toml"
|
||||
original = project.read_text()
|
||||
git(self.access, "rm", "pyproject.toml")
|
||||
(self.access / ".gitignore").write_text("/pyproject.toml\n")
|
||||
git(self.access, "add", ".gitignore")
|
||||
git(self.access, "commit", "-m", "Ignored metadata absent from frozen tree")
|
||||
project.write_text(original)
|
||||
self.assertEqual("", git_text(self.access, "status", "--porcelain"))
|
||||
result = self.tag(apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("must be tracked", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_lightweight_and_different_annotation_objects_remain_immutable(self):
|
||||
git(self.access, "tag", "v0.1.10")
|
||||
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||
git(self.access, "tag", "-d", "v0.1.10")
|
||||
git(self.access, "tag", "-a", "v0.1.10", "-m", "Original immutable annotation")
|
||||
git(self.access, "push", "origin", "refs/tags/v0.1.10")
|
||||
original = git_text(self.access_remote, "rev-parse", "refs/tags/v0.1.10")
|
||||
git(self.access, "tag", "-d", "v0.1.10")
|
||||
git(
|
||||
self.access,
|
||||
"tag",
|
||||
"-a",
|
||||
"v0.1.10",
|
||||
"-m",
|
||||
"Conflicting immutable annotation",
|
||||
)
|
||||
self.assertEqual("blocked", self.tag(apply=True, push=True)["status"])
|
||||
self.assertEqual(
|
||||
original, git_text(self.access_remote, "rev-parse", "refs/tags/v0.1.10")
|
||||
)
|
||||
|
||||
def test_changed_source_after_preflight_stops_before_first_batch_effect(self):
|
||||
original = source_tag_batch._preview_repositories
|
||||
|
||||
def changed(**kwargs):
|
||||
result = original(**kwargs)
|
||||
(self.access / "changed-source.txt").write_text(
|
||||
"new source after preflight\n"
|
||||
)
|
||||
git(self.access, "add", ".")
|
||||
git(self.access, "commit", "-m", "Source changed")
|
||||
return result
|
||||
|
||||
with patch.object(
|
||||
source_tag_batch, "_preview_repositories", side_effect=changed
|
||||
):
|
||||
result = self.tag(
|
||||
repos=("govoplan-core", "govoplan-access"), apply=True, push=True
|
||||
)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn("receipt changed", result["repositories"][0]["detail"])
|
||||
self.assert_no_tags()
|
||||
|
||||
def test_false_push_success_is_not_a_receipt_and_never_retries(self):
|
||||
original = source_tag_batch.run
|
||||
pushes = []
|
||||
|
||||
def run(command, **kwargs):
|
||||
if command[:3] == ("git", "push", "--atomic"):
|
||||
pushes.append(command)
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
return original(command, **kwargs)
|
||||
|
||||
with patch.object(source_tag_batch, "run", side_effect=run):
|
||||
result = self.tag(
|
||||
repos=("govoplan-access", "govoplan-core"), apply=True, push=True
|
||||
)
|
||||
self.assertEqual("partial", result["status"], result)
|
||||
self.assertEqual(1, len(pushes))
|
||||
self.assertEqual("skipped", result["repositories"][1]["status"])
|
||||
self.assertFalse(ref_exists(self.core, "refs/tags/v0.1.10"))
|
||||
self.assertFalse(ref_exists(self.access_remote, "refs/tags/v0.1.10"))
|
||||
|
||||
def _ready_bundle(self):
|
||||
self.repo = self.core # Existing fixture's Core name.
|
||||
campaign, remote = (
|
||||
release_fixture.ReleaseRepositoryTagTests._staged_campaign_bundle(self)
|
||||
)
|
||||
self.specs.append(
|
||||
{
|
||||
"name": "govoplan-campaign",
|
||||
"category": "module",
|
||||
"subtype": "domain",
|
||||
"path": "govoplan-campaign",
|
||||
"remote": str(remote),
|
||||
}
|
||||
)
|
||||
self.registry.write_text(json.dumps({"repositories": self.specs}))
|
||||
self.assertEqual(
|
||||
"tagged", self.tag(repos=("govoplan-campaign",), apply=True)["status"]
|
||||
)
|
||||
lock_path = self.core / "webui/package-lock.release.json"
|
||||
payload = json.loads(lock_path.read_text())
|
||||
package = payload["packages"]["node_modules/@govoplan/campaign-webui"]
|
||||
package["version"] = self.version
|
||||
package["resolved"] = (
|
||||
"git+ssh://git@example.test/acme/govoplan-campaign.git#"
|
||||
+ git_text(campaign, "rev-parse", "HEAD")
|
||||
)
|
||||
lock_path.write_text(json.dumps(payload))
|
||||
# Core is a reviewed input only here: do not require an unrelated tag
|
||||
# or silently impose a new clean-Core prerequisite for module release.
|
||||
return campaign, remote, lock_path
|
||||
|
||||
def test_module_publication_freezes_core_inputs_without_requiring_core_tag(self):
|
||||
_campaign, _remote, _lock = self._ready_bundle()
|
||||
self.assertFalse(ref_exists(self.core, "refs/tags/v0.1.10"))
|
||||
result = self.tag(repos=("govoplan-campaign",), apply=True, push=True)
|
||||
self.assertEqual("published", result["status"], result)
|
||||
self.assertEqual(["govoplan-campaign"], list(result["source_receipts"]))
|
||||
self.assertEqual(
|
||||
{"webui/package.release.json", "webui/package-lock.release.json"},
|
||||
set(result["bundle_input_receipts"]),
|
||||
)
|
||||
self.assertFalse(ref_exists(self.core, "refs/tags/v0.1.10"))
|
||||
|
||||
def test_changed_or_group_writable_core_bundle_inputs_block_module_publication(
|
||||
self,
|
||||
):
|
||||
campaign, remote, lock = self._ready_bundle()
|
||||
original = source_tag_batch._preview_repositories
|
||||
|
||||
def changed(**kwargs):
|
||||
result = original(**kwargs)
|
||||
lock.write_text(lock.read_text() + "\n")
|
||||
return result
|
||||
|
||||
with patch.object(
|
||||
source_tag_batch, "_preview_repositories", side_effect=changed
|
||||
):
|
||||
result = self.tag(repos=("govoplan-campaign",), apply=True, push=True)
|
||||
self.assertEqual("blocked", result["status"], result)
|
||||
self.assertIn(
|
||||
"bundle input receipt changed", result["repositories"][0]["detail"]
|
||||
)
|
||||
self.assertFalse(ref_exists(remote, "refs/tags/v0.1.10"))
|
||||
lock.chmod(0o666)
|
||||
try:
|
||||
self.assertEqual(
|
||||
"blocked",
|
||||
self.tag(repos=("govoplan-campaign",), apply=True, push=True)["status"],
|
||||
)
|
||||
finally:
|
||||
lock.chmod(0o644)
|
||||
self.assertTrue(ref_exists(campaign, "refs/tags/v0.1.10"))
|
||||
self.assertFalse(ref_exists(remote, "refs/tags/v0.1.10"))
|
||||
|
||||
def test_read_only_shared_preflight_has_no_apply_or_mutating_legacy_entry(self):
|
||||
import inspect
|
||||
from govoplan_release import repository_tag
|
||||
|
||||
self.assertNotIn(
|
||||
"apply", inspect.signature(repository_tag._preview_repositories).parameters
|
||||
)
|
||||
self.assertFalse(hasattr(repository_tag, "_tag_repositories_legacy"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user