diff --git a/docs/operations/RELEASE_CONSOLE.md b/docs/operations/RELEASE_CONSOLE.md
index 18ae26d..ca1e9de 100644
--- a/docs/operations/RELEASE_CONSOLE.md
+++ b/docs/operations/RELEASE_CONSOLE.md
@@ -221,6 +221,9 @@ Repository capabilities are frozen into each plan unit (`python-package`,
`core-release-bundle`, and the universal `git-source`) and determine which
steps appear. Internally aligned version changes are rendered deterministically
from recognized TOML, JSON, lockfile, manifest, and package declarations.
+The manifest may use a literal version or a top-level literal `MODULE_VERSION`;
+the latter is updated without rewriting independently versioned interfaces.
+Computed or missing version declarations fail before any metadata is written.
Pre-existing dirty worktrees remain visible but have no commit executor; the
console never absorbs unrelated operator changes.
@@ -232,6 +235,17 @@ runs a receipt-bound alignment gate before exposing any atomic branch/tag push.
A failed step stops later steps while preserving prior receipts for explicit
retry or reconciliation.
+Local module candidate creation deliberately does not require those candidates
+to be resolved already in Core's release lock: their annotated tags are inputs
+to the next lock-generation step. The internal tag helper applies this ordering
+only when no Core repository is selected and remote publication is disabled.
+Module version/lock consistency, manifest validity, clean/non-behind worktrees,
+and local/remote tag immutability checks still apply. Core candidate tagging
+continues to validate its own complete bundle, and every remote-publication
+preview and execution requires the selected modules to match Core's release
+input and resolved lock. A local candidate is therefore not publication
+approval; a stale Core lock blocks publication without changing remote refs.
+
The browser likewise retains the request identifier for an uncertain
resume/retry/reconciliation response and replays it after reload. A successful
replay selects the returned run state. Transport and server failures retain the
@@ -515,8 +529,9 @@ updated catalog, and keep the published keyring healthy.
When a selected module exposes a WebUI package, its requested version must also
match Core's `webui/package.release.json` input and the resolved
-`package-lock.release.json` entry. The source-tag preflight, selective plan, and
-catalog-candidate writer all enforce this composition boundary. Pins for modules
+`package-lock.release.json` entry. The source-publication preflight, selective
+plan, and catalog-candidate writer all enforce this composition boundary;
+module-only local candidate tags use the staged order described above. Pins for modules
that are not part of the selective release remain unchanged.
Release integration also enforces repository and composition version alignment
diff --git a/packages/govoplan-meta/pyproject.toml b/packages/govoplan-meta/pyproject.toml
index 46b974f..8a751ab 100644
--- a/packages/govoplan-meta/pyproject.toml
+++ b/packages/govoplan-meta/pyproject.toml
@@ -4,46 +4,46 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan"
-version = "0.1.44"
+version = "0.1.45"
description = "Developer convenience package for a versioned GovOPlaN composition"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "AGPL-3.0-or-later" }
dependencies = [
- "govoplan-core[server]==0.1.44",
- "govoplan-tenancy==0.1.21",
- "govoplan-organizations==0.1.20",
+ "govoplan-core[server]==0.1.45",
+ "govoplan-tenancy==0.1.22",
+ "govoplan-organizations==0.1.21",
"govoplan-identity==0.1.20",
- "govoplan-idm==0.1.24",
- "govoplan-access==0.1.24",
- "govoplan-admin==0.1.22",
- "govoplan-policy==0.1.22",
+ "govoplan-idm==0.1.25",
+ "govoplan-access==0.1.25",
+ "govoplan-admin==0.1.23",
+ "govoplan-policy==0.1.23",
"govoplan-audit==0.1.20",
"govoplan-dashboard==0.1.20",
- "govoplan-files==0.1.25",
- "govoplan-mail==0.1.26",
- "govoplan-campaign==0.1.27",
+ "govoplan-files==0.1.26",
+ "govoplan-mail==0.1.27",
+ "govoplan-campaign==0.1.28",
"govoplan-calendar==0.1.23",
- "govoplan-docs==0.1.22",
- "govoplan-ops==0.1.21",
+ "govoplan-docs==0.1.23",
+ "govoplan-ops==0.1.22",
]
[project.optional-dependencies]
full = [
- "govoplan-addresses==0.1.21",
+ "govoplan-addresses==0.1.22",
"govoplan-approvals==0.1.20",
"govoplan-assets==0.1.20",
"govoplan-booking==0.1.20",
- "govoplan-cases==0.1.23",
+ "govoplan-cases==0.1.24",
"govoplan-certificates==0.1.20",
- "govoplan-committee==0.1.20",
- "govoplan-connectors==0.1.25",
+ "govoplan-committee==0.1.21",
+ "govoplan-connectors==0.1.26",
"govoplan-consultation==0.1.20",
"govoplan-contracts==0.1.20",
- "govoplan-dataflow==0.1.23",
- "govoplan-datasources==0.1.24",
+ "govoplan-dataflow==0.1.24",
+ "govoplan-datasources==0.1.25",
"govoplan-decisions==0.1.19",
- "govoplan-dist-lists==0.1.20",
+ "govoplan-dist-lists==0.1.21",
"govoplan-dms==0.1.20",
"govoplan-encryption==0.1.19",
"govoplan-erp==0.1.20",
@@ -58,7 +58,7 @@ full = [
"govoplan-inspections==0.1.20",
"govoplan-learning==0.1.20",
"govoplan-mandates==0.1.19",
- "govoplan-notifications==0.1.19",
+ "govoplan-notifications==0.1.20",
"govoplan-parties==0.1.19",
"govoplan-payments==0.1.21",
"govoplan-permits==0.1.20",
@@ -69,22 +69,22 @@ full = [
"govoplan-projects==0.1.19",
"govoplan-quick-access==0.1.20",
"govoplan-records==0.1.23",
- "govoplan-reporting==0.1.20",
+ "govoplan-reporting==0.1.21",
"govoplan-resources==0.1.20",
"govoplan-rest==0.1.19",
- "govoplan-risk-compliance==0.1.20",
- "govoplan-scheduling==0.1.21",
- "govoplan-search==0.1.19",
+ "govoplan-risk-compliance==0.1.21",
+ "govoplan-scheduling==0.1.22",
+ "govoplan-search==0.1.20",
"govoplan-services==0.1.19",
"govoplan-soap==0.1.19",
- "govoplan-tasks==0.1.22",
- "govoplan-templates==0.1.21",
+ "govoplan-tasks==0.1.23",
+ "govoplan-templates==0.1.22",
"govoplan-tickets==0.1.22",
"govoplan-transparency==0.1.20",
- "govoplan-views==0.1.21",
- "govoplan-voting==0.1.20",
+ "govoplan-views==0.1.22",
+ "govoplan-voting==0.1.21",
"govoplan-wiki==0.1.22",
- "govoplan-workflow==0.1.22",
+ "govoplan-workflow==0.1.23",
"govoplan-workflow-engine==0.1.21",
"govoplan-xrechnung==0.1.21",
]
diff --git a/requirements-audit.txt b/requirements-audit.txt
index f2e9dda..3375c70 100644
--- a/requirements-audit.txt
+++ b/requirements-audit.txt
@@ -2,7 +2,7 @@ bandit>=1.8,<2
click>=8.3.3
filelock>=3.20.3
idna>=3.15
-pip>=26.1.2
+pip>=26.2
pip-audit>=2.9,<3
python-multipart>=0.0.31
radon>=6,<7
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 293ac6a..742e1bd 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -56,7 +56,7 @@ httpx2>=2.5,<3
filelock>=3.20.3
idna>=3.15
jsonschema>=4,<5
-pip>=26.1.2
+pip>=26.2
pip-audit>=2.9,<3
pytest>=9.0.3,<10
pygments>=2.20,<3
diff --git a/requirements-release.txt b/requirements-release.txt
index 559e288..7437e82 100644
--- a/requirements-release.txt
+++ b/requirements-release.txt
@@ -1,18 +1,18 @@
# Whole-product release install from immutable, independently versioned module tags.
# Only add a module after its referenced tag has been published.
../govoplan-core[server]
-govoplan-tenancy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tenancy.git@v0.1.21
-govoplan-organizations @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git@v0.1.20
+govoplan-tenancy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tenancy.git@v0.1.22
+govoplan-organizations @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git@v0.1.21
govoplan-identity @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity.git@v0.1.20
-govoplan-idm @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git@v0.1.24
-govoplan-access @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git@v0.1.24
-govoplan-admin @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git@v0.1.22
-govoplan-policy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git@v0.1.22
+govoplan-idm @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git@v0.1.25
+govoplan-access @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git@v0.1.25
+govoplan-admin @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git@v0.1.23
+govoplan-policy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git@v0.1.23
govoplan-audit @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git@v0.1.20
govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.20
-govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.25
-govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.26
-govoplan-campaign @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git@v0.1.27
+govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.26
+govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.27
+govoplan-campaign @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git@v0.1.28
govoplan-calendar @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git@v0.1.23
-govoplan-docs @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git@v0.1.22
-govoplan-ops @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git@v0.1.21
+govoplan-docs @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git@v0.1.23
+govoplan-ops @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git@v0.1.22
diff --git a/tests/test-jsx-value-imports.mjs b/tests/test-jsx-value-imports.mjs
new file mode 100644
index 0000000..3160bf3
--- /dev/null
+++ b/tests/test-jsx-value-imports.mjs
@@ -0,0 +1,15 @@
+import assert from "node:assert/strict";
+import { findTypeOnlyJsxImports } from "../tools/checks/check-jsx-value-imports.mjs";
+
+const findings = (source) => findTypeOnlyJsxImports([{ path: "/fixture.tsx", source }]).map((item) => item.component);
+assert.deepEqual(findings('import type { FormGrid, AuthInfo } from "@govoplan/core-webui"; const page = ;'), ["FormGrid"]);
+assert.deepEqual(findings('import { type FormGrid as Layout } from "ui"; const page = Content;'), ["Layout"]);
+assert.deepEqual(findings('import type Layout from "ui"; const page = ;'), ["Layout"]);
+assert.deepEqual(findings('import type * as ui from "ui"; const page = ;'), ["ui.Layout"]);
+assert.deepEqual(findings('import type { FormGrid } from "ui"; const page =
} />;'), ["FormGrid"]);
+assert.deepEqual(findings('import { FormGrid, type AuthInfo } from "ui"; const page = ;'), []);
+assert.deepEqual(findings('import type { FormGrid } from "ui"; function Page({ FormGrid }: Props) { return ; }'), []);
+assert.deepEqual(findings('import type * as ui from "ui"; function Page(ui: RuntimeControls) { return ; }'), []);
+assert.deepEqual(findings('import type { Layout } from "ui"; const page: Layout = {};'), []);
+assert.deepEqual(findings('import type { input } from "ui"; const page = ;'), []);
+console.log("JSX runtime-import AST regression tests passed (10 cases).");
diff --git a/tests/test_institutional_governance_journey.py b/tests/test_institutional_governance_journey.py
index 41cabbb..20aea7f 100644
--- a/tests/test_institutional_governance_journey.py
+++ b/tests/test_institutional_governance_journey.py
@@ -25,6 +25,11 @@ from govoplan_core.core.institutional import (
TemporalRevision,
service_launch_capability,
)
+from govoplan_core.core.temporal import (
+ TemporalDataContext,
+ bind_temporal_data_context,
+ reset_temporal_data_context,
+)
from govoplan_cases.backend.party_context import CasePartyContext
from govoplan_cases.backend.db.models import (
CaseAccessGrant,
@@ -134,6 +139,13 @@ class _Registry:
class InstitutionalGovernanceJourneyTests(unittest.TestCase):
+ def setUp(self) -> None:
+ # Portal's effective_at does not replace the SQL provider's request-local
+ # read clock. Keep both on the journey date, without bypassing validity
+ # filtering or extending the fixture's finite publication interval.
+ token = bind_temporal_data_context(TemporalDataContext(evaluated_at=NOW))
+ self.addCleanup(reset_temporal_data_context, token)
+
def test_service_to_formal_outcome_retains_governed_context(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
for table in (
@@ -220,13 +232,44 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
service_launch_capability("case"): object(),
}
)
- entry = PortalServiceDirectory(service_registry).list_entries(
+ directory = PortalServiceDirectory(service_registry)
+ for outside_interval in (
+ service.temporal.valid_from - timedelta(microseconds=1),
+ service.temporal.valid_to,
+ ):
+ with self.subTest(outside_interval=outside_interval):
+ token = bind_temporal_data_context(
+ TemporalDataContext(evaluated_at=outside_interval)
+ )
+ try:
+ # Keep Portal inside the valid interval: the real SQL
+ # provider must still exclude a service outside its own
+ # temporal read context, before Portal can project it.
+ self.assertEqual(
+ (),
+ directory.list_entries(
+ session,
+ principal,
+ tenant_id="tenant-1",
+ effective_at=NOW,
+ audiences=("resident",),
+ ),
+ )
+ finally:
+ reset_temporal_data_context(token)
+
+ entries = directory.list_entries(
session,
principal,
tenant_id="tenant-1",
effective_at=NOW,
audiences=("resident",),
- )[0]
+ )
+ self.assertEqual(
+ (service.reference,),
+ tuple(entry.definition.reference for entry in entries),
+ )
+ entry = entries[0]
self.assertTrue(entry.available)
intake = CaseServiceIntake().plan(
entry.definition,
diff --git a/tests/test_package_registry_release.py b/tests/test_package_registry_release.py
index c44d257..3c835d5 100644
--- a/tests/test_package_registry_release.py
+++ b/tests/test_package_registry_release.py
@@ -62,22 +62,37 @@ class PackageRegistryReleaseTests(unittest.TestCase):
encoding="utf-8"
)
)["project"]["version"]
+ meta_package = ROOT / "packages/govoplan-meta/pyproject.toml"
+ meta_project = tomllib.loads(
+ meta_package.read_text(encoding="utf-8")
+ )["project"]
+ expected_tasks_pin = next(
+ requirement
+ for requirement in (
+ *meta_project["dependencies"],
+ *meta_project["optional-dependencies"]["full"],
+ )
+ if requirement.startswith("govoplan-tasks==")
+ )
selected = PACKAGE_SET.parse_meta_package(
- ROOT / "packages/govoplan-meta/pyproject.toml",
+ meta_package,
core_version=core_version,
)
by_name = {item["name"]: item for item in selected}
self.assertIn("govoplan-core", by_name)
self.assertIn("govoplan-records", by_name)
- self.assertEqual("0.1.22", by_name["govoplan-tasks"]["version"])
+ self.assertEqual(
+ expected_tasks_pin,
+ f"govoplan-tasks=={by_name['govoplan-tasks']['version']}",
+ )
payload = PACKAGE_SET.generate_package_set(
core_version=core_version,
requirements=ROOT / "requirements-release.txt",
workspace=ROOT.parent,
profile="full",
- meta_package=ROOT / "packages/govoplan-meta/pyproject.toml",
+ meta_package=meta_package,
)
self.assertEqual("full", payload["profile"])
self.assertEqual(len(selected), len(payload["python"]))
diff --git a/tests/test_release_repository_tag.py b/tests/test_release_repository_tag.py
index 2a51238..e2a4642 100644
--- a/tests/test_release_repository_tag.py
+++ b/tests/test_release_repository_tag.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
+import runpy
import subprocess
import sys
import tempfile
@@ -214,21 +215,23 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
git(self.repo, "commit", "-m", "Add release WebUI composition")
git(self.repo, "push", "origin", "main")
- result = tag_repositories(
- repos=("govoplan-core", "govoplan-campaign"),
- repo_versions={"govoplan-core": "0.1.10", "govoplan-campaign": "0.1.10"},
- workspace_root=self.workspace,
- apply=True,
- push=True,
- )
+ for push in (False, True):
+ with self.subTest(push=push):
+ result = tag_repositories(
+ repos=("govoplan-core", "govoplan-campaign"),
+ repo_versions={"govoplan-core": "0.1.10", "govoplan-campaign": "0.1.10"},
+ workspace_root=self.workspace,
+ apply=True,
+ push=push,
+ )
- self.assertEqual("blocked", result["status"])
- self.assertIn("no selected repository was mutated", result["detail"])
- self.assertEqual("skipped", result["repositories"][0]["status"])
- self.assertEqual("blocked", result["repositories"][1]["status"])
- self.assertIn("release WebUI composition gate failed", result["repositories"][1]["detail"])
- for repository in (self.repo, self.remote, campaign, campaign_remote):
- self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
+ self.assertEqual("blocked", result["status"])
+ self.assertIn("no selected repository was mutated", result["detail"])
+ self.assertEqual("skipped", result["repositories"][0]["status"])
+ self.assertEqual("blocked", result["repositories"][1]["status"])
+ self.assertIn("release WebUI composition gate failed", result["repositories"][1]["detail"])
+ for repository in (self.repo, self.remote, campaign, campaign_remote):
+ self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
def test_api_keeps_legacy_source_release_preview_only(self) -> None:
with TestClient(
@@ -300,6 +303,183 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
self.assertIn("Signed Website Catalog", ui.text)
self.assertIn("Apply + Website Tag", ui.text)
+ def test_local_module_candidate_precedes_core_lock_but_publication_does_not(self) -> None:
+ campaign, campaign_remote = self._staged_campaign_bundle()
+ remotes = (self.remote, self.manifest_remote, campaign_remote)
+ remote_refs = {path: git_text(path, "show-ref") for path in remotes}
+ arguments = {
+ "repos": ("govoplan-campaign",),
+ "repo_versions": {"govoplan-campaign": "0.1.10"},
+ "workspace_root": self.workspace,
+ }
+
+ preview = tag_repositories(**arguments, apply=False, push=False)
+ self.assertEqual("planned", preview["status"], preview)
+ self.assertFalse(ref_exists(campaign, "refs/tags/v0.1.10"))
+ candidate = tag_repositories(**arguments, apply=True, push=False)
+ self.assertEqual("tagged", candidate["status"], candidate)
+ self.assertEqual("tag", git_text(campaign, "cat-file", "-t", "v0.1.10"))
+ tag_object = git_text(campaign, "rev-parse", "v0.1.10")
+ head = git_text(campaign, "rev-parse", "HEAD")
+ self.assertEqual(head, git_text(campaign, "rev-parse", "v0.1.10^{commit}"))
+
+ for apply in (False, True):
+ with self.subTest(publish_apply=apply):
+ blocked = tag_repositories(**arguments, apply=apply, push=True)
+ self.assertEqual("blocked", blocked["status"], blocked)
+ self.assertIn(
+ "release WebUI composition gate failed",
+ blocked["repositories"][0]["detail"],
+ )
+ self.assertEqual(tag_object, git_text(campaign, "rev-parse", "v0.1.10"))
+ self.assertEqual(remote_refs, {path: git_text(path, "show-ref") for path in remotes})
+
+ lock_path = self.repo / "webui" / "package-lock.release.json"
+ lock = json.loads(lock_path.read_text(encoding="utf-8"))
+ locked_campaign = lock["packages"]["node_modules/@govoplan/campaign-webui"]
+ locked_campaign["version"] = "0.1.10"
+ locked_campaign["resolved"] = f"git+ssh://git@example.test/acme/govoplan-campaign.git#{head}"
+ lock_path.write_text(json.dumps(lock) + "\n", encoding="utf-8")
+ git(self.repo, "add", "webui/package-lock.release.json")
+ git(self.repo, "commit", "-m", "Resolve reviewed local Campaign candidate")
+
+ core_candidate = tag_repositories(
+ repos=("govoplan-core",),
+ repo_versions={"govoplan-core": "0.1.10"},
+ workspace_root=self.workspace,
+ apply=True,
+ push=False,
+ )
+ self.assertEqual("tagged", core_candidate["status"], core_candidate)
+ self.assertEqual(remote_refs, {path: git_text(path, "show-ref") for path in remotes})
+ published = tag_repositories(**arguments, apply=True, push=True)
+ self.assertEqual("published", published["status"], published)
+ self.assertEqual(tag_object, git_text(campaign_remote, "rev-parse", "v0.1.10"))
+ self.assertEqual(head, git_text(campaign_remote, "rev-parse", "refs/heads/main"))
+ self.assertEqual(remote_refs[self.remote], git_text(self.remote, "show-ref"))
+
+ def test_local_core_candidate_still_requires_resolved_module_tags(self) -> None:
+ campaign, campaign_remote = self._staged_campaign_bundle()
+ for selected in (("govoplan-core",), ("govoplan-campaign", "govoplan-core")):
+ with self.subTest(selected=selected):
+ result = tag_repositories(
+ repos=selected,
+ repo_versions={repo: "0.1.10" for repo in selected},
+ workspace_root=self.workspace,
+ apply=True,
+ push=False,
+ )
+ self.assertEqual("blocked", result["status"], result)
+ core_row = next(row for row in result["repositories"] if row["repo"] == "govoplan-core")
+ self.assertIn("version alignment gate failed", core_row["detail"])
+ self.assertIn("@govoplan/campaign-webui:resolved", core_row["detail"])
+ self.assertIn("expected 'local tag v0.1.10'", core_row["detail"])
+ for repository in (self.repo, self.remote, campaign, campaign_remote):
+ self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
+
+ def test_local_module_candidate_preserves_version_and_worktree_gates(self) -> None:
+ campaign, campaign_remote = self._staged_campaign_bundle()
+ arguments = {
+ "repos": ("govoplan-campaign",),
+ "workspace_root": self.workspace,
+ "apply": True,
+ "push": False,
+ }
+ mismatch = tag_repositories(**arguments, repo_versions={"govoplan-campaign": "0.1.11"})
+ self.assertEqual("blocked", mismatch["status"], mismatch)
+ self.assertIn("version alignment gate failed", mismatch["repositories"][0]["detail"])
+
+ (campaign / "unreviewed.txt").write_text("operator work\n", encoding="utf-8")
+ dirty = tag_repositories(**arguments, repo_versions={"govoplan-campaign": "0.1.10"})
+ self.assertEqual("blocked", dirty["status"], dirty)
+ self.assertIn("worktree is not clean", dirty["repositories"][0]["detail"])
+ for repository in (campaign, campaign_remote):
+ for tag in ("v0.1.10", "v0.1.11"):
+ self.assertFalse(ref_exists(repository, f"refs/tags/{tag}"))
+
+ def test_local_module_candidate_preserves_manifest_gate(self) -> None:
+ campaign, campaign_remote = self._staged_campaign_bundle()
+ replace_with_unscoped_workflow_manifest(self.manifest_repo)
+ result = tag_repositories(
+ repos=("govoplan-campaign",),
+ repo_versions={"govoplan-campaign": "0.1.10"},
+ workspace_root=self.workspace,
+ apply=True,
+ push=False,
+ )
+ self.assertEqual("blocked", result["status"], result)
+ self.assertIn("scope-conditioned alternative", result["repositories"][0]["detail"])
+ for repository in (campaign, campaign_remote):
+ self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
+
+ def test_local_module_candidate_preserves_remote_tag_immutability(self) -> None:
+ campaign, campaign_remote = self._staged_campaign_bundle()
+ git(campaign, "tag", "-a", "v0.1.10", "-m", "Existing immutable tag", "v0.1.9^{commit}")
+ git(campaign, "push", "origin", "refs/tags/v0.1.10")
+ remote_refs = git_text(campaign_remote, "show-ref")
+ for local_exists in (True, False):
+ with self.subTest(local_exists=local_exists):
+ if not local_exists:
+ git(campaign, "tag", "-d", "v0.1.10")
+ result = tag_repositories(
+ repos=("govoplan-campaign",),
+ repo_versions={"govoplan-campaign": "0.1.10"},
+ workspace_root=self.workspace,
+ apply=True,
+ push=False,
+ )
+ self.assertEqual("blocked", result["status"], result)
+ self.assertIn("immutable tag", result["repositories"][0]["detail"])
+ self.assertIn("not HEAD", result["repositories"][0]["detail"])
+ self.assertEqual(remote_refs, git_text(campaign_remote, "show-ref"))
+ self.assertEqual(local_exists, ref_exists(campaign, "refs/tags/v0.1.10"))
+
+ def _staged_campaign_bundle(self) -> tuple[Path, Path]:
+ campaign, remote = create_release_repo(
+ root=self.root,
+ workspace=self.workspace,
+ name="govoplan-campaign",
+ version="0.1.9",
+ )
+ campaign_webui = campaign / "webui"
+ campaign_webui.mkdir()
+ package_path = campaign_webui / "package.json"
+ package_path.write_text(
+ '{"name":"@govoplan/campaign-webui","version":"0.1.9"}\n', encoding="utf-8"
+ )
+ git(campaign, "add", "webui/package.json")
+ git(campaign, "commit", "-m", "Prior Campaign WebUI package")
+ git(campaign, "tag", "-a", "v0.1.9", "-m", "Prior Campaign release")
+ git(campaign, "push", "origin", "main", "refs/tags/v0.1.9")
+ prior_commit = git_text(campaign, "rev-parse", "HEAD")
+ for path in (campaign / "pyproject.toml", package_path):
+ path.write_text(path.read_text(encoding="utf-8").replace("0.1.9", "0.1.10"), encoding="utf-8")
+ git(campaign, "add", "pyproject.toml", "webui/package.json")
+ git(campaign, "commit", "-m", "Reviewed Campaign candidate")
+
+ core_webui = self.repo / "webui"
+ core_webui.mkdir()
+ dependency_ref = "git+ssh://git@example.test/acme/govoplan-campaign.git#v0.1.10"
+ package = {
+ "name": "@govoplan/core-webui",
+ "version": "0.1.10",
+ "dependencies": {"@govoplan/campaign-webui": dependency_ref},
+ }
+ (core_webui / "package.release.json").write_text(json.dumps(package) + "\n", encoding="utf-8")
+ (core_webui / "package-lock.release.json").write_text(
+ json.dumps({"packages": {
+ "": package,
+ "node_modules/@govoplan/campaign-webui": {
+ "version": "0.1.9",
+ "resolved": f"git+ssh://git@example.test/acme/govoplan-campaign.git#{prior_commit}",
+ },
+ }}) + "\n",
+ encoding="utf-8",
+ )
+ git(self.repo, "add", "webui/package.release.json", "webui/package-lock.release.json")
+ git(self.repo, "commit", "-m", "Stage Core input before candidate lock resolution")
+ return campaign, remote
+
def git(cwd: Path, *args: str) -> None:
result = subprocess.run(
@@ -338,9 +518,50 @@ def add_scoped_workflow_manifest(repo: Path) -> None:
backend.mkdir(parents=True)
(package / "__init__.py").write_text("", encoding="utf-8")
(backend / "__init__.py").write_text("", encoding="utf-8")
+ # This small workspace still has to satisfy the real presentation contract.
+ # Keep that prerequisite shared by both valid and intentionally unscoped
+ # documentation fixtures, so each test reaches its intended release gate.
+ canonical_areas = runpy.run_path(
+ str(META_ROOT / "tools" / "checks" / "check-manifest-shapes.py")
+ )["CANONICAL_PRODUCT_AREAS"]
+ (backend / "release_fixture.py").write_text(
+ """from govoplan_core.core.modules import FrontendModule, ProductAreaContribution
+from govoplan_core.core.views import ViewSurface
+
+
+def fixture_frontend():
+ return FrontendModule(
+ module_id="access",
+ view_surfaces=(
+ ViewSurface(
+ id="access.section.release-fixture",
+ module_id="access",
+ kind="section",
+ label="Release fixture",
+ ),
+ ),
+ product_areas=tuple(
+ ProductAreaContribution(
+ id=area_id,
+ module_id="access",
+ label=label,
+ icon=icon,
+ description=description,
+ order=order,
+ surface_ids=("access.section.release-fixture",),
+ )
+ for area_id, (label, icon, description, order) in CANONICAL_AREAS.items()
+ ),
+ )
+
+
+CANONICAL_AREAS = """ + repr(canonical_areas) + "\n",
+ encoding="utf-8",
+ )
(backend / "manifest.py").write_text(
"""from govoplan_core.core.modules import DocumentationCondition, DocumentationTopic, ModuleManifest, PermissionDefinition
from govoplan_core.core.provider_governance import declared_module_architecture
+from .release_fixture import fixture_frontend
def get_manifest():
@@ -348,6 +569,7 @@ def get_manifest():
id="access",
name="Access",
version="0.1.10",
+ frontend=fixture_frontend(),
permissions=(
PermissionDefinition(
scope="access:item:read",
@@ -403,6 +625,7 @@ def replace_with_unscoped_workflow_manifest(repo: Path) -> None:
manifest.write_text(
"""from govoplan_core.core.modules import DocumentationTopic, ModuleManifest
from govoplan_core.core.provider_governance import declared_module_architecture
+from .release_fixture import fixture_frontend
def get_manifest():
@@ -410,6 +633,7 @@ def get_manifest():
id="access",
name="Access",
version="0.1.10",
+ frontend=fixture_frontend(),
documentation=(
DocumentationTopic(
id="access.workflow.unscoped",
diff --git a/tests/test_release_version_metadata.py b/tests/test_release_version_metadata.py
index fc6f3d9..c28e99d 100644
--- a/tests/test_release_version_metadata.py
+++ b/tests/test_release_version_metadata.py
@@ -13,12 +13,47 @@ if str(RELEASE_ROOT) not in sys.path:
sys.path.insert(0, str(RELEASE_ROOT))
from govoplan_release.version_metadata import ( # noqa: E402
+ VersionMetadataError,
apply_version_metadata_mutations,
version_metadata_mutations,
)
class ReleaseVersionMetadataTests(unittest.TestCase):
+ def test_updates_shared_module_version_without_rewriting_independent_interfaces(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ root = Path(temp_dir)
+ backend = root / "src" / "govoplan_example" / "backend"
+ backend.mkdir(parents=True)
+ manifest = backend / "manifest.py"
+ manifest.write_text(
+ 'MODULE_VERSION: str = "1.2.3"\n'
+ 'manifest = ModuleManifest(id="example", version=MODULE_VERSION,\n'
+ ' provides_interfaces=(ModuleInterfaceProvider(name="api", version="2.0"),))\n',
+ encoding="utf-8",
+ )
+ changed = apply_version_metadata_mutations(root, target_version="1.2.4")
+ self.assertEqual(("src/govoplan_example/backend/manifest.py",), changed)
+ self.assertIn('MODULE_VERSION: str = "1.2.4"', manifest.read_text())
+ self.assertIn('version="2.0"', manifest.read_text())
+ self.assertEqual((), version_metadata_mutations(root, target_version="1.2.4"))
+
+ def test_dynamic_module_version_fails_before_any_metadata_is_written(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ root = Path(temp_dir)
+ backend = root / "src" / "govoplan_example" / "backend"
+ backend.mkdir(parents=True)
+ project = root / "pyproject.toml"
+ project.write_text('[project]\nname="govoplan-example"\nversion="1.2.3"\n')
+ before = project.read_bytes()
+ (backend / "manifest.py").write_text(
+ 'MODULE_VERSION = compute_version()\n'
+ 'manifest = ModuleManifest(id="example", version=MODULE_VERSION)\n',
+ )
+ with self.assertRaisesRegex(VersionMetadataError, "no literal MODULE_VERSION"):
+ apply_version_metadata_mutations(root, target_version="1.2.4")
+ self.assertEqual(before, project.read_bytes())
+
def test_updates_recognized_metadata_without_changing_interface_versions(
self,
) -> None:
diff --git a/tests/test_security_audit_wrapper.py b/tests/test_security_audit_wrapper.py
index 5fa0dcd..ea0a1a7 100644
--- a/tests/test_security_audit_wrapper.py
+++ b/tests/test_security_audit_wrapper.py
@@ -98,6 +98,10 @@ class SecurityAuditWrapperTests(unittest.TestCase):
if [[ "${1:-}" == 'git' && "${2:-}" == '--help' ]]; then
exit 0
fi
+ if [[ " $* " != *" --redact=100 "* ]]; then
+ echo 'secret scans must redact reports and logs' >&2
+ exit 3
+ fi
output=''
while [[ $# -gt 0 ]]; do
if [[ "$1" == '--report-path' ]]; then
diff --git a/tools/checks/check-focused.sh b/tools/checks/check-focused.sh
index e765dd6..1ab465e 100644
--- a/tools/checks/check-focused.sh
+++ b/tools/checks/check-focused.sh
@@ -39,6 +39,9 @@ GOVOPLAN_CORE_ROOT="$ROOT" PYTHON="$PYTHON" CHECK_TESTCLIENT_DEPRECATIONS=1 bash
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
PYTHONDONTWRITEBYTECODE=1 "$PYTHON" "$META_ROOT/tools/checks/check-manifest-shapes.py" --require-architecture
PYTHONDONTWRITEBYTECODE=1 "$PYTHON" "$META_ROOT/tools/checks/check-dsar-coverage.py"
+"$NODE/node" "$META_ROOT/tests/test-jsx-value-imports.mjs"
+"$NODE/node" "$META_ROOT/tools/checks/check-jsx-value-imports.mjs"
+"$NODE/node" "$META_ROOT/../govoplan-files/webui/scripts/test-archive-client.mjs"
cd "$META_ROOT"
"$PYTHON" tools/inventory/platform-interface-inventory.py --strict-declarations --strict-endpoints
@@ -96,6 +99,15 @@ PY
"$PYTHON" "$META_ROOT/tools/checks/check-shared-webui-primitives.py"
"$PYTHON" "$META_ROOT/tools/checks/check-shared-webui-foundations.py"
"$PYTHON" -m unittest tests.test_module_system
+"$PYTHON" -m unittest tests.test_ownership_history_migration tests.test_ownership tests.test_ownership_api
+"$PYTHON" -m unittest tests.test_navigation_preferences tests.test_api_smoke.ApiSmokeTests.test_navigation_separator_layout_survives_system_tenant_and_personal_saves
+"$PYTHON" -m pytest -q \
+ /mnt/DATA/git/govoplan-files/tests/test_managed_archives.py \
+ /mnt/DATA/git/govoplan-files/tests/test_archive_work.py \
+ /mnt/DATA/git/govoplan-files/tests/test_archive_staging.py \
+ /mnt/DATA/git/govoplan-files/tests/test_upload_response_batching.py \
+ /mnt/DATA/git/govoplan-files/tests/test_archive_performance.py
+"$PYTHON" -m pytest -q /mnt/DATA/git/govoplan-access/tests/test_external_function_mapping_migration.py
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-connectors/tests
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-datasources/tests
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-dataflow/tests
@@ -116,13 +128,31 @@ PY
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-identity-trust/tests
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-encryption/tests
"$PYTHON" -m pytest -q /mnt/DATA/git/govoplan-wiki/tests
-"$PYTHON" -m pytest -q /mnt/DATA/git/govoplan-campaign/tests/test_approval_gate.py
+"$PYTHON" -m pytest -q \
+ /mnt/DATA/git/govoplan-campaign/tests/test_approval_gate.py \
+ /mnt/DATA/git/govoplan-campaign/tests/test_editor_state_security.py \
+ /mnt/DATA/git/govoplan-campaign/tests/test_mail_profile_boundary.py \
+ /mnt/DATA/git/govoplan-campaign/tests/test_independent_configuration_repairs.py \
+ /mnt/DATA/git/govoplan-campaign/tests/test_incremental_review_persistence.py \
+ /mnt/DATA/git/govoplan-campaign/tests/test_reviewed_build_mock.py \
+ /mnt/DATA/git/govoplan-campaign/tests/test_delivery_policy_settings.py \
+ /mnt/DATA/git/govoplan-campaign/tests/test_synchronous_delivery_policy.py \
+ /mnt/DATA/git/govoplan-campaign/tests/test_workerless_recovery.py \
+ /mnt/DATA/git/govoplan-campaign/tests/test_imap_batch_integration.py \
+ /mnt/DATA/git/govoplan-campaign/tests/test_testbed_claim_recovery.py \
+ /mnt/DATA/git/govoplan-campaign/tests/test_campaign_optimistic_concurrency.py \
+ /mnt/DATA/git/govoplan-campaign/tests/test_archive_encryption_governance.py \
+ /mnt/DATA/git/govoplan-policy/tests/test_campaign_archive_encryption.py \
+ /mnt/DATA/git/govoplan-policy/tests/test_archive_encryption_api.py
"$PYTHON" "$META_ROOT/tools/checks/check-datasource-composition.py"
"$PYTHON" "$META_ROOT/tools/checks/check-sanctions-screening-composition.py"
+"$PYTHON" -m pytest -q /mnt/DATA/git/govoplan-mail/tests/test_campaign_protocol_authorization.py /mnt/DATA/git/govoplan-mail/tests/test_campaign_imap_batch.py
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-mail/tests
"$PYTHON" -m unittest tests.test_api_smoke.ApiSmokeTests.test_mailbox_message_listing_reports_total_count
cd "$ROOT/webui"
+"$NPM" run test:api-client-cache
+"$NPM" run test:dependency-security
"$NPM" run test:layout-primitives
"$NPM" run test:mail-components
"$NPM" run test:module-capabilities
@@ -155,12 +185,19 @@ cd /mnt/DATA/git/govoplan-postbox/webui
cd /mnt/DATA/git/govoplan-mail/webui
"$NPM" run test:mail-ui
+cd /mnt/DATA/git/govoplan-files/webui
+"$NPM" run test:managed-archive
+
cd /mnt/DATA/git/govoplan-campaign/webui
"$NPM" run test:policy-ui
"$NPM" run test:template-preview
+"$NPM" run test:review-workflow
"$NPM" run test:accessibility-contract
"$NPM" run test:campaign-collaboration
"$NPM" run test:campaign-work
+cd /mnt/DATA/git/govoplan-policy/webui
+"$NPM" run test:archive-encryption
+
cd /mnt/DATA/git/govoplan-wiki/webui
"$NPM" run test:interface-pattern
diff --git a/tools/checks/check-jsx-value-imports.mjs b/tools/checks/check-jsx-value-imports.mjs
new file mode 100644
index 0000000..a3bbcc8
--- /dev/null
+++ b/tools/checks/check-jsx-value-imports.mjs
@@ -0,0 +1,73 @@
+#!/usr/bin/env node
+/** Reject erased type-only imports used as runtime JSX component tags. */
+import { readFileSync, readdirSync, existsSync } from "node:fs";
+import { createRequire } from "node:module";
+import { resolve, relative } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const workspaceRoot = resolve(import.meta.dirname, "../../..");
+const require = createRequire(resolve(workspaceRoot, "govoplan-core/webui/package.json"));
+const ts = require("typescript");
+
+function isTypeOnlyImport(declaration) {
+ if (ts.isImportSpecifier(declaration)) return declaration.isTypeOnly || declaration.parent.parent.isTypeOnly;
+ if (ts.isNamespaceImport(declaration)) return declaration.parent.isTypeOnly;
+ return (ts.isImportClause(declaration) || ts.isImportEqualsDeclaration(declaration)) && declaration.isTypeOnly;
+}
+
+/** Resolve lexical bindings, including shadowing; do not typecheck unrelated
+ * optional dependencies or report ordinary application diagnostics.
+ */
+export function findTypeOnlyJsxImports(sources) {
+ const files = new Map(sources.map(({ path, source }) => [resolve(path),
+ ts.createSourceFile(resolve(path), source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)]));
+ const options = { noEmit: true, noResolve: true, noLib: true, types: [], jsx: ts.JsxEmit.Preserve };
+ const host = ts.createCompilerHost(options);
+ host.getSourceFile = (path) => files.get(resolve(path));
+ const program = ts.createProgram([...files.keys()], options, host);
+ const checker = program.getTypeChecker();
+ const findings = [];
+ for (const [path, source] of files) {
+ function visit(node) {
+ if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
+ let root = node.tagName;
+ // Lower-case direct tags are intrinsic HTML, not runtime bindings.
+ if (!(ts.isIdentifier(root) && /^[a-z]/.test(root.text))) {
+ while (ts.isPropertyAccessExpression(root)) root = root.expression;
+ const declarations = checker.getSymbolAtLocation(root)?.declarations ?? [];
+ if (declarations.some(isTypeOnlyImport)) {
+ const position = source.getLineAndCharacterOfPosition(node.tagName.getStart(source));
+ findings.push({ path, line: position.line + 1, column: position.character + 1, component: node.tagName.getText(source) });
+ }
+ }
+ }
+ ts.forEachChild(node, visit);
+ }
+ visit(source);
+ }
+ return findings;
+}
+
+function sourceFiles(directory) {
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ const path = resolve(directory, entry.name);
+ return entry.isDirectory() ? sourceFiles(path) : entry.name.endsWith(".tsx") ? [path] : [];
+ });
+}
+
+export function checkWorkspace(root = workspaceRoot) {
+ const modules = readdirSync(root, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("govoplan"))
+ .map((entry) => resolve(root, entry.name, "webui/src")).filter(existsSync);
+ const paths = modules.flatMap(sourceFiles);
+ const findings = findTypeOnlyJsxImports(paths.map((path) => ({ path, source: readFileSync(path, "utf8") })));
+ for (const finding of findings) {
+ console.error(`${relative(root, finding.path)}:${finding.line}:${finding.column}: JSX component ${finding.component} is imported type-only and will be erased at runtime.`);
+ }
+ if (!findings.length) console.log(`JSX runtime-import contract passed: ${paths.length} TSX files across ${modules.length} WebUI modules.`);
+ return findings.length ? 1 : 0;
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ process.exitCode = checkWorkspace();
+}
diff --git a/tools/checks/check-security-audit.sh b/tools/checks/check-security-audit.sh
index 5a4a3a3..d04888d 100644
--- a/tools/checks/check-security-audit.sh
+++ b/tools/checks/check-security-audit.sh
@@ -677,6 +677,7 @@ run_gitleaks() {
prepare_machine_report "$REPORTS_DIR/gitleaks-history-$name.json" || return 2
prepare_machine_report "$REPORTS_DIR/gitleaks-worktree-$name.json" || return 2
gitleaks git \
+ --redact=100 \
--config "$ROOT/.gitleaks.toml" \
--report-format json \
--report-path "$REPORTS_DIR/gitleaks-history-$name.json" \
@@ -687,6 +688,7 @@ run_gitleaks() {
# Scan the directory as well so pre-commit audits cover the exact code
# under review, while retaining the history scan above.
gitleaks dir \
+ --redact=100 \
--config "$ROOT/.gitleaks.toml" \
--report-format json \
--report-path "$REPORTS_DIR/gitleaks-worktree-$name.json" \
@@ -696,6 +698,7 @@ run_gitleaks() {
else
prepare_machine_report "$REPORTS_DIR/gitleaks-$name.json" || return 2
gitleaks detect \
+ --redact=100 \
--source "$repo" \
--config "$ROOT/.gitleaks.toml" \
--report-format json \
diff --git a/tools/checks/check-shared-webui-primitives.py b/tools/checks/check-shared-webui-primitives.py
index cad2dc2..7bb3f05 100644
--- a/tools/checks/check-shared-webui-primitives.py
+++ b/tools/checks/check-shared-webui-primitives.py
@@ -86,6 +86,12 @@ CENTRAL_COMPONENTS = {
"CountBadge": pathlib.Path(
"govoplan-core/webui/src/components/CountBadge.tsx"
),
+ "MultiSelectFilter": pathlib.Path(
+ "govoplan-core/webui/src/components/MultiSelectFilter.tsx"
+ ),
+ "ListSelectionFilter": pathlib.Path(
+ "govoplan-core/webui/src/components/ListSelectionFilter.tsx"
+ ),
"SelectionList": pathlib.Path(
"govoplan-core/webui/src/components/SelectionList.tsx"
),
@@ -190,7 +196,21 @@ REQUIRED_CONSUMERS = {
"CountBadge": (
pathlib.Path("govoplan-core/webui/src/layout/Titlebar.tsx"),
pathlib.Path("govoplan-mail/webui/src/features/mail/MailboxPage.tsx"),
+ ),
+ # Search's old count badge was part of a retired module-local filter menu.
+ # Both surfaces must now compose the owning facet adapter and Core dropdown.
+ "SearchFilters": (
pathlib.Path("govoplan-search/webui/src/features/search/SearchPage.tsx"),
+ pathlib.Path("govoplan-search/webui/src/components/GlobalSearch.tsx"),
+ ),
+ "MultiSelectFilter": (
+ pathlib.Path("govoplan-search/webui/src/components/SearchFilters.tsx"),
+ pathlib.Path("govoplan-notifications/webui/src/features/notifications/NotificationCenterPage.tsx"),
+ pathlib.Path("govoplan-docs/webui/src/features/docs/DocsPage.tsx"),
+ ),
+ "ListSelectionFilter": (
+ pathlib.Path("govoplan-core/webui/src/components/MultiSelectFilter.tsx"),
+ pathlib.Path("govoplan-core/webui/src/components/table/DataGrid.tsx"),
),
"SelectionListItemContent": (
pathlib.Path("govoplan-approvals/webui/src/features/approvals/ApprovalsPage.tsx"),
diff --git a/tools/release/govoplan_release/repository_tag.py b/tools/release/govoplan_release/repository_tag.py
index 97ab7ed..cd2e973 100644
--- a/tools/release/govoplan_release/repository_tag.py
+++ b/tools/release/govoplan_release/repository_tag.py
@@ -39,6 +39,9 @@ def tag_repositories(
A release tag is only created for a clean, aligned, non-behind worktree.
Both local and remote tags are resolved to commits before mutation so an
existing immutable tag can never be moved by this operation.
+ Module-only local candidate tags precede Core's release-lock regeneration;
+ their cross-Core composition gate applies before publication, not creation.
+ Core candidate tags still require a complete aligned release bundle.
"""
workspace = resolve_workspace_root(workspace_root)
@@ -47,13 +50,19 @@ def tag_repositories(
selected = tuple(dict.fromkeys(repos))
results: list[dict[str, object]] = []
bundle_issues_by_repo: dict[str, list[str]] = {}
- for issue in selected_release_webui_bundle_issues(
- repo_versions={repo: repo_versions.get(repo, "") for repo in selected},
- workspace=workspace,
- ):
- bundle_issues_by_repo.setdefault(issue.repo, []).append(
- f"{issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})"
- )
+ # Core's final lock is generated from reviewed local module tags. Requiring
+ # that lock before those tags exist makes the documented sequence circular.
+ # This is only a local module staging exception: Core-selected batches and
+ # every publication still run the cross-repository gate, and each selected
+ # repository's own version/lock checks below are always enforced.
+ if push or "govoplan-core" in selected:
+ for issue in selected_release_webui_bundle_issues(
+ repo_versions={repo: repo_versions.get(repo, "") for repo in selected},
+ workspace=workspace,
+ ):
+ bundle_issues_by_repo.setdefault(issue.repo, []).append(
+ f"{issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})"
+ )
if apply:
preflight = tag_repositories(
diff --git a/tools/release/govoplan_release/version_metadata.py b/tools/release/govoplan_release/version_metadata.py
index 67742f0..d95a425 100644
--- a/tools/release/govoplan_release/version_metadata.py
+++ b/tools/release/govoplan_release/version_metadata.py
@@ -269,6 +269,7 @@ def _render_python_version(
except SyntaxError as exc:
raise VersionMetadataError(f"Python metadata is malformed: {path.name}") from exc
values: list[ast.Constant] = []
+ module_version_references = 0
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or _call_name(node.func) != target_name:
continue
@@ -279,6 +280,28 @@ def _render_python_version(
and isinstance(keyword.value.value, str)
):
values.append(keyword.value)
+ elif (
+ keyword.arg == keyword_name
+ and isinstance(keyword.value, ast.Name)
+ and keyword.value.id == "MODULE_VERSION"
+ ):
+ module_version_references += 1
+ if module_version_references:
+ if module_version_references != 1 or values:
+ raise VersionMetadataError(
+ f"Python metadata has multiple {target_name}.{keyword_name} values: {path.name}"
+ )
+ rendered, found = _render_python_assignment(
+ payload,
+ path=path,
+ assignment_name="MODULE_VERSION",
+ version=version,
+ )
+ if not found:
+ raise VersionMetadataError(
+ f"Python metadata has no literal MODULE_VERSION declaration: {path.name}"
+ )
+ return rendered, True
if not values:
return payload, False
if len(values) != 1: