diff --git a/docs/RELEASE_CONSOLE.md b/docs/RELEASE_CONSOLE.md index 7430a79..a39abfd 100644 --- a/docs/RELEASE_CONSOLE.md +++ b/docs/RELEASE_CONSOLE.md @@ -65,7 +65,10 @@ selected branch and annotated tag. The gate requires an aligned target version, a clean named branch with a HEAD, and a checkout that is not behind. Existing local or remote tags must resolve to the selected HEAD and are never moved. The local and remote annotated tag objects must also be identical, not merely -point at the same commit. +point at the same commit. Before any source tag is created, the console loads +the cross-repository module registry. This release gate also rejects every +user-facing workflow documentation topic that has no scope condition, or has +an alternative condition without `required_scopes` or `any_scopes`. The catalog workflow panel can also operate on the same selected rows: diff --git a/tests/test_release_entrypoint_gates.py b/tests/test_release_entrypoint_gates.py index cd91646..efa275f 100644 --- a/tests/test_release_entrypoint_gates.py +++ b/tests/test_release_entrypoint_gates.py @@ -24,7 +24,9 @@ class ReleaseEntrypointGateTests(unittest.TestCase): 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] + manifest_gate = script.index("run_manifest_shape_gate\n\nconfirm_release") + confirm = script.index("\nconfirm_release\n", manifest_gate) + workflow = script[confirm:] source_gate = workflow.index("run_version_alignment_gate source") first_commit = workflow.index('run git -C "$repo" commit') @@ -36,6 +38,7 @@ class ReleaseEntrypointGateTests(unittest.TestCase): self.assertLess(first_commit, lock_generation) self.assertLess(lock_generation, full_gate) self.assertLess(full_gate, first_push) + self.assertLess(manifest_gate, confirm) def test_source_catalog_generator_enforces_explicit_repo_versions(self) -> None: script = (META_ROOT / "tools" / "release" / "generate-release-catalog.py").read_text() diff --git a/tests/test_release_repository_tag.py b/tests/test_release_repository_tag.py index 822efbd..6e2958c 100644 --- a/tests/test_release_repository_tag.py +++ b/tests/test_release_repository_tag.py @@ -30,6 +30,13 @@ class ReleaseRepositoryTagTests(unittest.TestCase): name="govoplan-core", version="0.1.10", ) + self.manifest_repo, self.manifest_remote = create_release_repo( + root=self.root, + workspace=self.workspace, + name="govoplan-access", + version="0.1.10", + ) + add_scoped_workflow_manifest(self.manifest_repo) def tearDown(self) -> None: self.temporary.cleanup() @@ -95,13 +102,42 @@ class ReleaseRepositoryTagTests(unittest.TestCase): self.assertIn("worktree is not clean", dirty["repositories"][0]["detail"]) self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10")) - def test_batch_preflight_blocks_every_mutation_when_a_later_repo_is_dirty(self) -> None: - access, access_remote = create_release_repo( + def test_user_workflow_scope_contract_blocks_source_tagging(self) -> None: + replace_with_unscoped_workflow_manifest(self.manifest_repo) + + result = tag_repositories( + repos=("govoplan-core",), + repo_versions={"govoplan-core": "0.1.10"}, + workspace_root=self.workspace, + ) + + self.assertEqual(result["status"], "blocked") + self.assertIn("scope-conditioned alternative", result["repositories"][0]["detail"]) + self.assertFalse(ref_exists(self.repo, "refs/tags/v0.1.10")) + + def test_empty_manifest_workspace_blocks_source_tagging(self) -> None: + workspace = self.root / "empty-manifest-workspace" + workspace.mkdir() + core, _ = create_release_repo( root=self.root, - workspace=self.workspace, - name="govoplan-access", + workspace=workspace, + name="govoplan-core", version="0.1.10", ) + + result = tag_repositories( + repos=("govoplan-core",), + repo_versions={"govoplan-core": "0.1.10"}, + workspace_root=workspace, + ) + + self.assertEqual(result["status"], "blocked") + self.assertIn("No module manifests found", result["repositories"][0]["detail"]) + self.assertFalse(ref_exists(core, "refs/tags/v0.1.10")) + + def test_batch_preflight_blocks_every_mutation_when_a_later_repo_is_dirty(self) -> None: + access = self.manifest_repo + access_remote = self.manifest_remote (access / "uncommitted.txt").write_text("not reviewed\n", encoding="utf-8") result = tag_repositories( @@ -183,6 +219,86 @@ def create_release_repo(*, root: Path, workspace: Path, name: str, version: str) return repo, remote +def add_scoped_workflow_manifest(repo: Path) -> None: + package = repo / "src" / "govoplan_access" + backend = package / "backend" + backend.mkdir(parents=True) + (package / "__init__.py").write_text("", encoding="utf-8") + (backend / "__init__.py").write_text("", encoding="utf-8") + (backend / "manifest.py").write_text( + """from govoplan_core.core.modules import DocumentationCondition, DocumentationTopic, ModuleManifest, PermissionDefinition + + +def get_manifest(): + return ModuleManifest( + id="access", + name="Access", + version="0.1.10", + permissions=( + PermissionDefinition( + scope="access:item:read", + module_id="access", + resource="item", + action="read", + label="Read items", + description="Read example items.", + category="Example", + level="tenant", + ), + ), + documentation=( + DocumentationTopic( + id="access.workflow.scoped", + title="Scoped task", + summary="This is a valid release fixture.", + documentation_types=("user",), + conditions=(DocumentationCondition(required_scopes=("access:item:read",)),), + metadata={"kind": "workflow"}, + ), + ), + ) +""", + encoding="utf-8", + ) + with (repo / "pyproject.toml").open("a", encoding="utf-8") as handle: + handle.write( + '\n[project.entry-points."govoplan.modules"]\n' + 'access = "govoplan_access.backend.manifest:get_manifest"\n' + ) + git(repo, "add", "pyproject.toml", "src") + git(repo, "commit", "-m", "Add scoped workflow manifest") + git(repo, "push", "origin", "main") + + +def replace_with_unscoped_workflow_manifest(repo: Path) -> None: + manifest = repo / "src" / "govoplan_access" / "backend" / "manifest.py" + manifest.write_text( + """from govoplan_core.core.modules import DocumentationTopic, ModuleManifest + + +def get_manifest(): + return ModuleManifest( + id="access", + name="Access", + version="0.1.10", + documentation=( + DocumentationTopic( + id="access.workflow.unscoped", + title="Unscoped task", + summary="This must block release.", + documentation_types=("user",), + metadata={"kind": "workflow"}, + ), + ), + ) +""", + encoding="utf-8", + ) + git(repo, "add", str(manifest.relative_to(repo))) + git(repo, "commit", "-m", "Replace scoped workflow with unscoped workflow") + git(repo, "push", "origin", "main") + + def git_text(cwd: Path, *args: str) -> str: result = subprocess.run( ("git", *args), diff --git a/tools/release/govoplan_release/repository_tag.py b/tools/release/govoplan_release/repository_tag.py index d813c0a..680fa6b 100644 --- a/tools/release/govoplan_release/repository_tag.py +++ b/tools/release/govoplan_release/repository_tag.py @@ -3,15 +3,17 @@ from __future__ import annotations from dataclasses import dataclass +import os from pathlib import Path import re import subprocess +import sys from .git_state import collect_repository_snapshot, git_text from .model import RepositorySnapshot from .repository_push import command_text, compact_output from .version_alignment import repository_version_issues -from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root +from .workspace import META_ROOT, load_repository_specs, resolve_repo_path, resolve_workspace_root _RELEASE_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") @@ -75,6 +77,24 @@ def tag_repositories( "detail": "batch preflight failed; no selected repository was mutated", "repositories": blocked_rows, } + elif selected: + manifest_gate_issue = manifest_shape_gate_issue(workspace) + if manifest_gate_issue: + return { + "status": "blocked", + "apply": False, + "push": push, + "remote": remote, + "detail": "release manifest validation failed", + "repositories": [ + { + "repo": repo, + "status": "blocked", + "detail": manifest_gate_issue, + } + for repo in selected + ], + } for repo in selected: version = normalize_version(repo_versions.get(repo)) @@ -352,6 +372,20 @@ def basic_blocker(*, snapshot: RepositorySnapshot, version: str) -> str | None: return None +def manifest_shape_gate_issue(workspace: Path) -> str | None: + command = ( + sys.executable, + str(META_ROOT / "tools" / "checks" / "check-manifest-shapes.py"), + "--workspace-root", + str(workspace), + ) + result = run(command, cwd=META_ROOT, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}) + if result.returncode == 0: + return None + detail = compact_output(result.stderr) or compact_output(result.stdout) + return f"manifest/documentation release gate failed: {detail or 'unknown validation error'}" + + @dataclass(frozen=True, slots=True) class RemoteTagResult: commit: str | None = None diff --git a/tools/release/push-release-tag.sh b/tools/release/push-release-tag.sh index 1a06b3b..1d712a4 100644 --- a/tools/release/push-release-tag.sh +++ b/tools/release/push-release-tag.sh @@ -493,6 +493,13 @@ run_version_alignment_gate() { run "${command[@]}" } +run_manifest_shape_gate() { + run env PYTHONDONTWRITEBYTECODE=1 \ + "$PYTHON" \ + "$META_ROOT/tools/checks/check-manifest-shapes.py" \ + --workspace-root "$PARENT" +} + run_migration_release_audit() { local audit_script="$META_ROOT/tools/release/release-migration-audit.py" local command=("$PYTHON" "$audit_script" "--track" "release") @@ -841,6 +848,7 @@ for repo in "${REPOS[@]}"; do done run_migration_release_audit +run_manifest_shape_gate confirm_release