331 lines
13 KiB
Python
331 lines
13 KiB
Python
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()
|