Complete guided release console workflow
Dependency Audit / dependency-audit (push) Successful in 2m35s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Failing after 15s

This commit is contained in:
2026-07-31 05:46:50 +02:00
parent b4248a849e
commit f1fd143ef5
13 changed files with 2332 additions and 117 deletions
+46 -28
View File
@@ -22,10 +22,17 @@ effects can be verified safely:
- freeze a selective plan as a durable, resumable local release run
- durably preflight a creation-time-bound repository, create its annotated tag,
and publish its branch/tag pair atomically
- deterministically update recognized package/manifest version declarations and
commit only the receipt-bound metadata paths
- order selected module providers before consumers, create module tags before
Core, regenerate Core's selected WebUI release lock, and re-run alignment
before any remote push
- build selected Python wheels and generate a private, signed, receipt-bound
catalog candidate
- publish that exact candidate through a verified website commit and immutable
tag after explicit confirmation
- install selected candidate wheels into a private no-network/no-dependency
target and verify their installed metadata against the frozen plan
Start it from the meta repository:
@@ -79,21 +86,23 @@ from the saved-run selector. Problems in unselected repositories remain visible
as workspace notices but do not lock an unrelated release; the selective plan
is the authority for blockers in the selected repository set.
Installation verification remains an explicit unavailable phase after a
durable run completes. It is currently enforced by release-integration CI,
rather than being presented as a successful local-console step. This prevents
the guide from treating source publication as proof that the published package
can be installed and started.
Installation verification is an explicit durable step after catalog
publication. It verifies the exact candidate receipt, installs every selected
Python wheel into a temporary target with network and dependency resolution
disabled, and compares installed names and versions with the frozen plan.
Deployment startup, database upgrades, and module-combination smoke tests remain
release-integration CI gates; the console does not represent its local wheel
check as a production deployment.
`Build Plan` also returns structured release-gate findings for each selected
repository. The plan names the recommended next action and gives an explicit
remediation for source-version, lockfile, Core WebUI composition, Git state, and
worktree findings. A target version that has not yet been applied consistently
to the selected source tree is therefore explained before the source-tag
preflight rather than appearing only as an error after the operator tries to
tag. `source_preflight_ready` means that the plan-visible source gates pass; the
non-mutating `Preview Tag + Publish` remains mandatory for remote, manifest, and
immutable-tag checks.
worktree findings. A target version that differs from internally consistent
source metadata becomes a bounded `UPDATE` step. Unsupported, missing, or
internally inconsistent declarations remain a blocker with exact remediation.
`source_preflight_ready` means that plan-visible source gates pass or have a
bounded deterministic mutation; the non-mutating `Preview Tag + Publish`
remains mandatory for remote, manifest, and immutable-tag checks.
## Durable release runs
@@ -103,11 +112,12 @@ requires the plan to resolve exactly the requested repositories and target
versions; the browser cannot submit or replace the plan snapshot. The input and
plan are then immutable and covered by a canonical SHA-256 integrity digest.
Every executable repository step also carries its creation-time full HEAD,
branch, worktree state, target tag, and a SHA-256 over both the fetch and push
URLs of `origin`. Both URLs must exactly equal the remote registered in
`repositories.json`; a changed HEAD, branch, worktree, remote, or push URL
requires a new run rather than silently retargeting the frozen compatibility
decision.
branch, target tag, a SHA-256 over both the fetch and push URLs of `origin`, and
a SHA-256 over bounded dirty-path names and bytes. Both URLs must exactly equal
the remote registered in `repositories.json`; a changed HEAD, branch, worktree,
remote, push URL, or metadata byte requires a new run or explicit
interrupted-step reconciliation rather than silently retargeting the frozen
compatibility decision.
The complete record also has a checksum so a valid-looking manual edit to its
mutable state fails closed. File permissions remain the authority boundary;
these digests detect accidental or manual corruption, not an attacker who can
@@ -195,24 +205,32 @@ retry. The UI keeps unavailable controls visible and disabled.
Supported executors durably claim the step before invoking an effect. Exact
attempt replays return the recorded outcome and never invoke the executor a
second time. Successful repository preflight, tag, and push steps persist a
bounded repository-state receipt. Tag reconciliation independently requires an
second time. Successful repository preflight, version, commit, Core-bundle,
tag, and push steps persist a bounded repository-state receipt. Version
reconciliation requires aligned declarations in the same commit; commit
reconciliation requires the expected single-parent release commit and only
recognized metadata paths. Tag reconciliation independently requires an
annotated local tag at the frozen HEAD; push reconciliation additionally
requires both the remote annotated tag object and remote branch to match.
Catalog generation persists
only its server-issued opaque candidate ID and canonical catalog SHA-256, then
re-resolves and re-hashes that private candidate before publication.
The safe baseline is intentionally narrower than the complete dry-run plan.
Dirty worktrees and version changes still show commit/version steps, but those
steps have no durable executor because the frozen run does not bind the exact
proposed file content. A run selecting Core together with one or more modules
begins with a disabled dependency-ordering barrier: local module tags, Core
release-lock regeneration/commit, Core tagging, alignment verification, and
pushes need an explicit DAG executor before that composition can mutate.
Operators must prepare and review those changes outside the console and create
a new run from the resulting clean HEAD. The console never skips these steps or
claims an end-to-end release succeeded.
Repository capabilities are frozen into each plan unit (`python-package`,
`webui-package`, `module-manifest`, `database-migrations`, `documentation`,
`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.
Pre-existing dirty worktrees remain visible but have no commit executor; the
console never absorbs unrelated operator changes.
For mixed releases, module interface providers are ordered before consumers
and Core is tagged last. The durable sequence creates and commits module
metadata, creates local module tags, updates Core's selected WebUI references,
regenerates the release lock against those local tags, commits/tags Core, and
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.
The browser likewise retains the request identifier for an uncertain
resume/retry/reconciliation response and replays it after reload. A successful
+166 -3
View File
@@ -9,6 +9,7 @@ import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
import zipfile
META_ROOT = Path(__file__).resolve().parents[1]
@@ -17,6 +18,7 @@ if str(RELEASE_ROOT) not in sys.path:
sys.path.insert(0, str(RELEASE_ROOT))
from govoplan_release.model import RepositorySpec # noqa: E402
from govoplan_release.artifact_identity import inspect_python_wheel # noqa: E402
from govoplan_release.git_state import sanitized_git_environment # noqa: E402
from govoplan_release.release_execution import ( # noqa: E402
ExecutorSpec,
@@ -30,6 +32,7 @@ from govoplan_release.release_execution import ( # noqa: E402
require_trusted_release_runtime,
verify_frozen_repository_tag_receipt,
verify_repository_preflight_binding,
verify_candidate_installation,
_clone_catalog_sources,
_checkout_frozen_source,
_flatpak_proxy_environment,
@@ -489,9 +492,8 @@ class ReleaseExecutionTests(unittest.TestCase):
expected_receipt={"kind": "repository_state"},
)
def test_commit_step_has_no_durable_executor(self) -> None:
self.assertIsNone(
executor_spec(
def test_commit_step_has_narrow_confirmation(self) -> None:
spec = executor_spec(
{
"id": "govoplan-files:commit",
"status": "planned",
@@ -502,7 +504,168 @@ class ReleaseExecutionTests(unittest.TestCase):
},
}
)
self.assertIsNotNone(spec)
self.assertEqual("commit", spec.kind)
self.assertEqual("COMMIT", spec.confirmation)
def test_version_and_commit_executors_bind_only_release_metadata(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
workspace = Path(temp_dir)
repository = workspace / "govoplan-files"
remote = workspace / "files.git"
_git(workspace, "init", "--bare", str(remote))
_git(workspace, "init", "-b", "main", str(repository))
_git(repository, "config", "user.name", "Release Test")
_git(repository, "config", "user.email", "release@example.test")
(repository / "pyproject.toml").write_text(
'[project]\nname = "govoplan-files"\nversion = "1.2.3"\n',
encoding="utf-8",
)
_git(repository, "add", "pyproject.toml")
_git(repository, "commit", "-m", "Initial")
_git(repository, "remote", "add", "origin", str(remote))
_git(repository, "push", "-u", "origin", "main")
repository_spec = _repository_spec(remote)
plan_step = {
"id": "govoplan-files:version",
"repo": "govoplan-files",
"source_binding": {"kind": "repository_state"},
}
with (
patch(
"govoplan_release.release_execution.load_repository_specs",
return_value=(repository_spec,),
),
patch(
"govoplan_release.release_execution.require_trusted_release_runtime"
),
):
before = repository_state_receipt(
repo="govoplan-files",
target_tag="v1.2.4",
workspace_root=workspace,
)
_result, updated = execute_repository_step(
spec=ExecutorSpec(
"version",
"UPDATE",
"version_metadata_updated",
True,
),
plan_step=plan_step,
repo_versions={"govoplan-files": "1.2.4"},
workspace_root=workspace,
remote="origin",
expected_receipt=before,
)
self.assertFalse(updated["worktree_clean"])
self.assertNotEqual(
before["worktree_sha256"],
updated["worktree_sha256"],
)
plan_step["id"] = "govoplan-files:commit"
_result, committed = execute_repository_step(
spec=ExecutorSpec(
"commit",
"COMMIT",
"release_commit_created",
True,
),
plan_step=plan_step,
repo_versions={"govoplan-files": "1.2.4"},
workspace_root=workspace,
remote="origin",
expected_receipt=updated,
)
self.assertTrue(committed["worktree_clean"])
self.assertNotEqual(before["head"], committed["head"])
self.assertIn(
'version = "1.2.4"',
(repository / "pyproject.toml").read_text(encoding="utf-8"),
)
def test_candidate_installation_verifies_installed_metadata(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
workspace = Path(temp_dir)
repository = workspace / "govoplan-files"
repository.mkdir()
(repository / "pyproject.toml").write_text(
'[project]\nname = "govoplan-files"\nversion = "1.2.4"\n',
encoding="utf-8",
)
artifacts = workspace / "candidate" / "artifacts"
artifacts.mkdir(parents=True)
wheel = artifacts / "govoplan_files-1.2.4-py3-none-any.whl"
members = {
"govoplan_files/__init__.py": '__version__ = "1.2.4"\n',
"govoplan_files-1.2.4.dist-info/METADATA": (
"Metadata-Version: 2.1\n"
"Name: govoplan-files\n"
"Version: 1.2.4\n"
),
"govoplan_files-1.2.4.dist-info/WHEEL": (
"Wheel-Version: 1.0\n"
"Generator: release-test\n"
"Root-Is-Purelib: true\n"
"Tag: py3-none-any\n"
),
}
record = "\n".join(f"{name},," for name in members)
record += "\ngovoplan_files-1.2.4.dist-info/RECORD,,\n"
with zipfile.ZipFile(wheel, "w") as archive:
for name, content in members.items():
archive.writestr(name, content)
archive.writestr(
"govoplan_files-1.2.4.dist-info/RECORD",
record,
)
identity = inspect_python_wheel(wheel)
channels = workspace / "candidate" / "channels"
channels.mkdir()
(channels / "stable.json").write_text(
json.dumps(
{
"release": {
"selected_units": [
{
"repo": "govoplan-files",
"version": "1.2.4",
}
],
"artifacts": [
{
"package_name": "govoplan-files",
"archive_sha256": identity.archive_sha256,
}
],
},
"core_release": {},
"modules": [
{
"python_package": "govoplan-files",
"python_ref": (
"govoplan-files @ "
"git+ssh://git@example.test/GovOPlaN/"
"govoplan-files.git@v1.2.4"
),
}
],
}
)
+ "\n",
encoding="utf-8",
)
result = verify_candidate_installation(
candidate_path=workspace / "candidate",
repo_versions={"govoplan-files": "1.2.4"},
)
self.assertEqual("verified", result["status"])
self.assertEqual("govoplan-files", result["packages"][0]["repo"])
self.assertEqual("1.2.4", result["packages"][0]["version"])
def test_frozen_checkout_uses_receipt_commit_not_live_worktree(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
+32
View File
@@ -47,6 +47,38 @@ branch_labels: Union[str, Sequence[str], None] = None
self.assertEqual(migration.depends_on, ("core",))
self.assertEqual(migration.branch_labels, ())
def test_development_wrapper_uses_release_revision_metadata(self) -> None:
audit = load_audit_module()
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
root = Path(directory)
versions = root / "versions"
development = root / "dev_versions"
versions.mkdir()
development.mkdir()
release = versions / "1234_example.py"
release.write_text(
'revision = "1234"\n'
'down_revision = "base"\n'
'depends_on = "core"\n'
"branch_labels = None\n",
encoding="utf-8",
)
wrapper = development / release.name
wrapper.write_text(
"revision = _migration.revision\n"
"down_revision = _migration.down_revision\n"
"depends_on = _migration.depends_on\n"
"branch_labels = _migration.branch_labels\n",
encoding="utf-8",
)
migration = audit.parse_migration_file("govoplan-core", wrapper)
self.assertIsNotNone(migration)
self.assertEqual("1234", migration.revision)
self.assertEqual(("base",), migration.down_revisions)
self.assertEqual(("core",), migration.depends_on)
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
audit = load_audit_module()
migrations = [
+105 -14
View File
@@ -23,7 +23,7 @@ from govoplan_release.selective_planner import build_selective_release_plan # n
class ReleasePlanGuidanceTests(unittest.TestCase):
def test_unprepared_target_has_structured_remediation_and_recommendation(
def test_unprepared_target_gets_bounded_version_and_commit_steps(
self,
) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
@@ -40,20 +40,25 @@ class ReleasePlanGuidanceTests(unittest.TestCase):
repo_versions={"govoplan-files": "1.2.4"},
)
self.assertEqual("blocked", plan.status)
self.assertFalse(plan.source_preflight_ready)
finding = next(
item
self.assertEqual("attention", plan.status)
self.assertTrue(plan.source_preflight_ready)
self.assertFalse(
any(
item.code == "repository_version_alignment"
for item in plan.gate_findings
if item.code == "repository_version_alignment"
)
self.assertEqual("govoplan-files", finding.repo)
self.assertEqual("pyproject.toml", finding.source)
self.assertEqual("1.2.4", finding.expected)
self.assertEqual("1.2.3", finding.actual)
self.assertIn("Regenerate lockfiles", finding.remediation)
self.assertEqual("resolve_release_gate", plan.recommended_action.id)
self.assertEqual("govoplan-files", plan.recommended_action.repo)
)
steps = {step.id: step for step in plan.dry_run_steps}
self.assertEqual("planned", steps["govoplan-files:version"].status)
self.assertEqual("planned", steps["govoplan-files:commit"].status)
self.assertIn("pyproject.toml", steps["govoplan-files:version"].detail)
self.assertTrue(
any(
"version metadata will be updated" in warning
for warning in plan.units[0].warnings
)
)
self.assertIn("python-package", plan.units[0].capabilities)
def test_aligned_target_recommends_non_mutating_tag_preview(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
@@ -74,6 +79,91 @@ class ReleasePlanGuidanceTests(unittest.TestCase):
self.assertEqual("preview_source_release", plan.recommended_action.id)
self.assertIn("Preview Tag + Publish", plan.recommended_action.remediation)
def test_mixed_plan_tags_modules_before_core_and_aligns_before_push(
self,
) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
workspace = Path(temp_dir)
for repo_name in ("govoplan-core", "govoplan-files"):
repo_path = workspace / repo_name
repo_path.mkdir()
(repo_path / "pyproject.toml").write_text(
f'[project]\nname = "{repo_name}"\nversion = "1.2.3"\n',
encoding="utf-8",
)
repositories = tuple(
RepositorySnapshot(
spec=RepositorySpec(
name=repo_name,
category="core" if repo_name == "govoplan-core" else "module",
subtype="platform" if repo_name == "govoplan-core" else "infrastructure",
remote=f"git@example.test:GovOPlaN/{repo_name}.git",
path=repo_name,
),
absolute_path=str(workspace / repo_name),
exists=True,
is_git=True,
has_head=True,
branch="main",
versions=VersionSnapshot(pyproject="1.2.3"),
local_target_tag_exists=False,
)
for repo_name in ("govoplan-core", "govoplan-files")
)
base = dashboard(workspace=workspace, version="1.2.3")
mixed = ReleaseDashboard(
generated_at=base.generated_at,
meta_root=base.meta_root,
workspace_root=base.workspace_root,
target_version=None,
target_tag=None,
online=False,
include_migrations=False,
summary=DashboardSummary(
repository_count=2,
missing_count=0,
dirty_count=0,
ahead_count=0,
behind_count=0,
no_head_count=0,
error_count=0,
safe_directory_count=0,
local_target_tag_missing_count=0,
status="ready",
),
repositories=repositories,
catalog=base.catalog,
)
plan = build_selective_release_plan(
mixed,
selected_repos=("govoplan-core", "govoplan-files"),
repo_versions={
"govoplan-core": "1.2.3",
"govoplan-files": "1.2.3",
},
)
self.assertEqual(
["govoplan-files", "govoplan-core"],
[unit.repo for unit in plan.units],
)
step_ids = [step.id for step in plan.dry_run_steps]
self.assertLess(
step_ids.index("govoplan-files:tag"),
step_ids.index("govoplan-core:tag"),
)
self.assertLess(
step_ids.index("govoplan-core:tag"),
step_ids.index("release:alignment"),
)
self.assertLess(
step_ids.index("release:alignment"),
step_ids.index("govoplan-files:push"),
)
self.assertEqual("release:install-verify", step_ids[-1])
core = next(unit for unit in plan.units if unit.repo == "govoplan-core")
self.assertIn("core-release-bundle", core.capabilities)
def test_malformed_version_metadata_is_a_structured_blocker(self) -> None:
cases = (
("package.json", "{not-json\n", "JSONDecodeError"),
@@ -167,7 +257,8 @@ class ReleasePlanGuidanceTests(unittest.TestCase):
self.assertIn("data-run-step-id", webui)
self.assertIn("invalidateReleaseDraft", webui)
self.assertIn("Installation Verification", webui)
self.assertIn("external release-integration CI gate", webui)
self.assertIn("receipt-bound local gate", webui)
self.assertIn('step.id === "release:install-verify"', webui)
self.assertIn('const inspectionNotices = (summary.missing_count || 0)', webui)
self.assertIn('(summary.repository_count || 0) === 0', webui)
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
import json
from pathlib import Path
import sys
import tempfile
import unittest
META_ROOT = Path(__file__).resolve().parents[1]
RELEASE_ROOT = META_ROOT / "tools" / "release"
if str(RELEASE_ROOT) not in sys.path:
sys.path.insert(0, str(RELEASE_ROOT))
from govoplan_release.version_metadata import ( # noqa: E402
apply_version_metadata_mutations,
version_metadata_mutations,
)
class ReleaseVersionMetadataTests(unittest.TestCase):
def test_updates_recognized_metadata_without_changing_interface_versions(
self,
) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
package = root / "src" / "govoplan_example"
backend = package / "backend"
webui = root / "webui"
backend.mkdir(parents=True)
webui.mkdir()
(root / "pyproject.toml").write_text(
'[project]\nname = "govoplan-example"\nversion = "1.2.3"\n',
encoding="utf-8",
)
(root / "package.json").write_text(
'{"name":"root","version":"1.2.3"}\n',
encoding="utf-8",
)
(webui / "package.json").write_text(
'{"name":"@govoplan/example-webui","version":"1.2.3"}\n',
encoding="utf-8",
)
(webui / "package-lock.json").write_text(
json.dumps(
{
"name": "@govoplan/example-webui",
"version": "1.2.3",
"packages": {
"": {
"name": "@govoplan/example-webui",
"version": "1.2.3",
}
},
}
)
+ "\n",
encoding="utf-8",
)
(backend / "manifest.py").write_text(
"def get_manifest():\n"
" return ModuleManifest(\n"
' id="example",\n'
' version="1.2.3",\n'
" provides_interfaces=(\n"
' ModuleInterfaceProvider(name="example.api", version="4.0"),\n'
" ),\n"
" )\n",
encoding="utf-8",
)
(package / "__init__.py").write_text(
'__version__ = "1.2.3"\n',
encoding="utf-8",
)
preview = version_metadata_mutations(
root,
target_version="1.2.4",
)
changed = apply_version_metadata_mutations(
root,
target_version="1.2.4",
)
self.assertEqual(
{mutation.path for mutation in preview},
set(changed),
)
self.assertEqual(
"1.2.4",
json.loads((webui / "package-lock.json").read_text())[
"packages"
][""]["version"],
)
manifest = (backend / "manifest.py").read_text(encoding="utf-8")
self.assertIn('version="1.2.4"', manifest)
self.assertIn('version="4.0"', manifest)
self.assertEqual(
'__version__ = "1.2.4"\n',
(package / "__init__.py").read_text(encoding="utf-8"),
)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -222,6 +222,7 @@ class ReleasePlanUnit:
status: str
blockers: tuple[str, ...] = ()
warnings: tuple[str, ...] = ()
capabilities: tuple[str, ...] = ()
provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = ()
requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = ()
gate_findings: tuple[ReleaseGateFinding, ...] = ()
File diff suppressed because it is too large Load Diff
@@ -2218,7 +2218,7 @@ def _validated_result_receipt(
"Catalog publication receipt identity is invalid."
)
return dict(value)
if set(value) != {
repository_fields = {
"kind",
"repo",
"head",
@@ -2228,7 +2228,8 @@ def _validated_result_receipt(
"worktree_clean",
"target_tag",
"tag_object",
}:
}
if set(value) not in (repository_fields, repository_fields | {"worktree_sha256"}):
raise ReleaseRunConflict("Repository result receipt is invalid.")
if (
not isinstance(value.get("repo"), str)
@@ -2241,6 +2242,10 @@ def _validated_result_receipt(
or value.get("remote") != "origin"
or not _is_sha256(value.get("remote_sha256"))
or type(value.get("worktree_clean")) is not bool
or (
"worktree_sha256" in value
and not _is_sha256(value.get("worktree_sha256"))
)
or not isinstance(value.get("target_tag"), str)
or len(value["target_tag"]) > 160
or (
@@ -25,6 +25,10 @@ from .version_alignment import (
selected_release_webui_bundle_issues,
selected_repository_version_issues,
)
from .version_metadata import (
VersionMetadataError,
version_metadata_mutations,
)
def build_selective_release_plan(
@@ -46,6 +50,7 @@ def build_selective_release_plan(
)
for repo in repositories
)
units = dependency_ordered_units(units)
units = apply_repository_version_gate(
units, workspace=Path(dashboard.workspace_root)
)
@@ -103,7 +108,35 @@ def apply_repository_version_gate(
workspace: Path,
) -> tuple[ReleasePlanUnit, ...]:
issues_by_repo: dict[str, list[ReleaseGateFinding]] = {}
version_update_supported_by_repo: dict[str, bool] = {}
deferred_core_lock_repos: set[str] = set()
for unit in units:
version_update_supported = unit.current_version == unit.target_version
if unit.current_version and unit.current_version != unit.target_version:
try:
version_update_supported = bool(
version_metadata_mutations(
workspace / unit.repo,
target_version=unit.target_version,
)
)
except VersionMetadataError as exc:
issues_by_repo.setdefault(unit.repo, []).append(
ReleaseGateFinding(
code="repository_version_mutation_unsupported",
severity="blocker",
message=str(exc),
remediation=(
"Align the unsupported version declarations manually, "
"commit the reviewed result, then rebuild this plan."
),
repo=unit.repo,
source="repository version metadata",
expected=unit.target_version,
actual=unit.current_version,
)
)
version_update_supported_by_repo[unit.repo] = version_update_supported
try:
issues = selected_repository_version_issues(
repo_versions={unit.repo: unit.target_version},
@@ -130,6 +163,18 @@ def apply_repository_version_gate(
)
continue
for issue in issues:
if (
version_update_supported
and issue.message
== "source version must match the requested release version"
):
continue
if (
unit.repo == "govoplan-core"
and issue.source.startswith("package-lock.release.json")
):
deferred_core_lock_repos.add(unit.repo)
continue
issues_by_repo.setdefault(issue.repo, []).append(
ReleaseGateFinding(
code="repository_version_alignment",
@@ -161,17 +206,68 @@ def apply_repository_version_gate(
source_preflight_ready=False,
)
if unit.repo in issues_by_repo
else unit
else replace(
unit,
warnings=planned_version_warnings(
unit,
defer_core_lock=unit.repo in deferred_core_lock_repos,
),
status=(
"attention"
if (
(
unit.current_version
and unit.current_version != unit.target_version
)
or unit.repo in deferred_core_lock_repos
)
and unit.status == "ready"
else unit.status
),
source_preflight_ready=(
unit.source_preflight_ready
or (
bool(unit.current_version)
and unit.current_version != unit.target_version
and version_update_supported_by_repo.get(unit.repo, False)
and not any(
finding.code == "worktree_dirty"
for finding in unit.gate_findings
)
)
),
)
for unit in units
)
def planned_version_warnings(
unit: ReleasePlanUnit,
*,
defer_core_lock: bool,
) -> tuple[str, ...]:
warnings = list(unit.warnings)
if unit.current_version and unit.current_version != unit.target_version:
warnings.append(
"version metadata will be updated by the bounded release executor"
)
if defer_core_lock:
warnings.append(
"Core release lock metadata will be regenerated by the bounded "
"bundle executor"
)
return tuple(warnings)
def apply_release_webui_bundle_gate(
units: tuple[ReleasePlanUnit, ...],
*,
workspace: Path,
) -> tuple[ReleasePlanUnit, ...]:
issues_by_repo: dict[str, list[ReleaseGateFinding]] = {}
selected = {unit.repo for unit in units}
core_selected = "govoplan-core" in selected
planned_bundle_repos: set[str] = set()
try:
issues = selected_release_webui_bundle_issues(
repo_versions={unit.repo: unit.target_version for unit in units},
@@ -200,6 +296,9 @@ def apply_release_webui_bundle_gate(
)
issues = ()
for issue in issues:
if core_selected and issue.repo in selected and issue.repo != "govoplan-core":
planned_bundle_repos.add(issue.repo)
continue
issues_by_repo.setdefault(issue.repo, []).append(
ReleaseGateFinding(
code="release_webui_composition_alignment",
@@ -230,7 +329,25 @@ def apply_release_webui_bundle_gate(
source_preflight_ready=False,
)
if unit.repo in issues_by_repo
else unit
else replace(
unit,
warnings=unit.warnings
+ (
(
"Core release WebUI references will be regenerated after "
"the selected module tags are created"
),
)
if unit.repo == "govoplan-core" and planned_bundle_repos
else unit.warnings,
status=(
"attention"
if unit.repo == "govoplan-core"
and planned_bundle_repos
and unit.status == "ready"
else unit.status
),
)
for unit in units
)
@@ -408,6 +525,7 @@ def build_unit(
status="blocked" if blockers else "attention" if warnings else "ready",
blockers=tuple(blockers),
warnings=tuple(warnings),
capabilities=repository_capabilities(repo, contracts=contracts),
provides_interfaces=provides,
requires_interfaces=requires,
gate_findings=tuple(findings),
@@ -416,11 +534,83 @@ def build_unit(
and not repo.dirty
and repo.has_head
and bool(repo.branch)
and current_version == resolved_target
and current_version is not None
),
)
def repository_capabilities(
repo: RepositorySnapshot,
*,
contracts: ModuleContractSnapshot | None,
) -> tuple[str, ...]:
"""Describe the release operations applicable to one repository shape."""
root = Path(repo.absolute_path)
capabilities = {"git-source"}
if (root / "pyproject.toml").is_file():
capabilities.add("python-package")
if (root / "package.json").is_file() or (root / "webui/package.json").is_file():
capabilities.add("webui-package")
if contracts is not None:
capabilities.add("module-manifest")
if (
(root / "alembic/versions").is_dir()
or (root / "src").is_dir()
and any((root / "src").glob("**/migrations/versions"))
):
capabilities.add("database-migrations")
if (root / "docs").is_dir() or repo.spec.subtype in {"docs", "documentation"}:
capabilities.add("documentation")
if repo.spec.name == "govoplan-core":
capabilities.add("core-release-bundle")
if repo.spec.name == "govoplan":
capabilities.add("release-control-plane")
return tuple(sorted(capabilities))
def dependency_ordered_units(
units: tuple[ReleasePlanUnit, ...],
) -> tuple[ReleasePlanUnit, ...]:
"""Order module providers before consumers while keeping Core last."""
by_repo = {unit.repo: unit for unit in units}
providers: dict[str, set[str]] = {}
for unit in units:
if unit.repo == "govoplan-core":
continue
for provided in unit.provides_interfaces:
providers.setdefault(provided.name, set()).add(unit.repo)
dependencies: dict[str, set[str]] = {unit.repo: set() for unit in units}
for unit in units:
for requirement in unit.requires_interfaces:
dependencies[unit.repo].update(
provider
for provider in providers.get(requirement.name, set())
if provider != unit.repo
)
if "govoplan-core" in dependencies:
dependencies["govoplan-core"].update(
repo for repo in by_repo if repo != "govoplan-core"
)
ordered: list[ReleasePlanUnit] = []
remaining = set(by_repo)
while remaining:
ready = sorted(
repo
for repo in remaining
if not (dependencies[repo] & remaining)
)
if not ready:
ready = [sorted(remaining)[0]]
for repo in ready:
ordered.append(by_repo[repo])
remaining.remove(repo)
return tuple(ordered)
def gate_finding(
*,
repo: str,
@@ -501,24 +691,38 @@ def dry_run_steps(
) -> tuple[ReleasePlanStep, ...]:
steps: list[ReleasePlanStep] = []
snapshots = {repo.spec.name: repo for repo in dashboard.repositories}
selected_names = {unit.repo for unit in units}
if "govoplan-core" in selected_names and len(selected_names) > 1:
steps.append(
ReleasePlanStep(
id="release:cross-repository-ordering",
title="Resolve module/Core release ordering",
detail=(
"A combined Core/module release needs a dependency-aware DAG: "
"local module tags, Core release-lock regeneration and commit, "
"Core tag, full alignment, then atomic pushes. The current linear "
"executor must not publish a module before that lock is committed."
),
command=None,
cwd=dashboard.meta_root,
mutating=True,
status="needs-executor",
core_unit = next((unit for unit in units if unit.repo == "govoplan-core"), None)
non_core_units = tuple(unit for unit in units if unit.repo != "govoplan-core")
bundle_repos: tuple[str, ...] = ()
core_lock_regeneration = False
if core_unit is not None:
try:
bundle_repos = tuple(
sorted(
{
issue.repo
for issue in selected_release_webui_bundle_issues(
repo_versions={
unit.repo: unit.target_version for unit in units
},
workspace=Path(dashboard.workspace_root),
)
if issue.repo != "govoplan-core"
}
)
)
core_lock_regeneration = bool(bundle_repos) or any(
issue.source.startswith("package-lock.release.json")
for issue in selected_repository_version_issues(
repo_versions={
"govoplan-core": core_unit.target_version,
},
workspace=Path(dashboard.workspace_root),
)
)
except Exception: # noqa: BLE001 - a structured gate already reports it.
bundle_repos = ()
for unit in units:
steps.append(
ReleasePlanStep(
@@ -531,32 +735,74 @@ def dry_run_steps(
repo=unit.repo,
)
)
for unit in units:
if unit.current_version != unit.target_version:
try:
paths = tuple(
mutation.path
for mutation in version_metadata_mutations(
Path(dashboard.workspace_root) / unit.repo,
target_version=unit.target_version,
)
)
except VersionMetadataError:
paths = ()
snapshot = snapshots.get(unit.repo)
version_supported = bool(
paths and snapshot is not None and not snapshot.dirty
)
steps.append(
ReleasePlanStep(
id=f"{unit.repo}:version",
title=f"Update {unit.repo} version metadata",
detail=f"Prepare version files and manifest metadata for {unit.target_version}.",
command=None,
detail=(
f"Update only the recognized version declarations for "
f"{unit.target_version}: {', '.join(paths) or 'unsupported metadata'}."
),
command=(
"bounded version metadata update "
+ " ".join(shlex.quote(path) for path in paths)
)
if paths
else None,
cwd=f"{dashboard.workspace_root}/{unit.repo}",
mutating=True,
repo=unit.repo,
status="needs-executor",
status="planned" if version_supported else "needs-executor",
)
)
for unit in non_core_units:
snapshot = snapshots.get(unit.repo)
if unit.current_version != unit.target_version or (
needs_commit = unit.current_version != unit.target_version or (
snapshot is not None and snapshot.dirty
):
)
if needs_commit:
commit_supported = bool(
unit.current_version != unit.target_version
and snapshot is not None
and not snapshot.dirty
)
steps.append(
ReleasePlanStep(
id=f"{unit.repo}:commit",
title=f"Commit {unit.repo} release changes",
detail="Commit reviewed changes for this release unit only.",
command=f"git add -A && git commit -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}",
detail=(
"Commit only the receipt-bound files written by the version "
"executor."
if commit_supported
else "Pre-existing worktree changes require review outside the release executor."
),
command=(
f"git commit -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}"
if commit_supported
else None
),
cwd=f"{dashboard.workspace_root}/{unit.repo}",
mutating=True,
repo=unit.repo,
status="planned" if commit_supported else "needs-executor",
)
)
steps.append(
@@ -570,6 +816,97 @@ def dry_run_steps(
repo=unit.repo,
)
)
if core_unit is not None:
if core_lock_regeneration:
steps.append(
ReleasePlanStep(
id="govoplan-core:bundle",
title="Regenerate the Core release WebUI bundle",
detail=(
"After local module tags exist, update and lock the exact "
"selected WebUI references"
+ (
" for: " + ", ".join(bundle_repos)
if bundle_repos
else ""
)
+ ", and reconcile package/lock metadata."
),
command=(
"tools/release/generate-release-lock.sh "
+ " ".join(
f"--local-git-repo {shlex.quote(str(Path(dashboard.workspace_root) / repo))}"
for repo in bundle_repos
)
),
cwd=dashboard.meta_root,
mutating=True,
repo="govoplan-core",
)
)
core_snapshot = snapshots.get(core_unit.repo)
core_needs_commit = (
core_unit.current_version != core_unit.target_version
or core_lock_regeneration
or (core_snapshot is not None and core_snapshot.dirty)
)
if core_needs_commit:
core_commit_supported = bool(
core_snapshot is not None
and not core_snapshot.dirty
and (
core_unit.current_version != core_unit.target_version
or core_lock_regeneration
)
)
steps.append(
ReleasePlanStep(
id="govoplan-core:commit",
title="Commit govoplan-core release changes",
detail=(
"Commit only the receipt-bound Core version and release-bundle files."
if core_commit_supported
else "Pre-existing Core worktree changes require review outside the release executor."
),
command=(
f"git commit -m {shlex.quote(f'Release govoplan-core {core_unit.target_tag}')}"
if core_commit_supported
else None
),
cwd=f"{dashboard.workspace_root}/govoplan-core",
mutating=True,
repo="govoplan-core",
status="planned" if core_commit_supported else "needs-executor",
)
)
steps.append(
ReleasePlanStep(
id="govoplan-core:tag",
title=f"Create govoplan-core tag {core_unit.target_tag}",
detail="Create the Core tag only after selected module tags and the release lock are verified.",
command=f"git tag -a {shlex.quote(core_unit.target_tag)} -m {shlex.quote(f'Release govoplan-core {core_unit.target_tag}')}",
cwd=f"{dashboard.workspace_root}/govoplan-core",
mutating=True,
repo="govoplan-core",
)
)
if units:
steps.append(
ReleasePlanStep(
id="release:alignment",
title="Verify frozen source and release composition",
detail=(
"Re-run version, contract, tag, and Core WebUI composition gates "
"after local mutations and before any remote publication."
),
command="receipt-bound release alignment",
cwd=dashboard.meta_root,
)
)
for unit in units:
steps.append(
ReleasePlanStep(
id=f"{unit.repo}:push",
@@ -640,6 +977,20 @@ def dry_run_steps(
status="planned",
)
)
steps.append(
ReleasePlanStep(
id="release:install-verify",
title="Verify candidate package installation",
detail=(
"Install every selected candidate wheel into a private "
"temporary target without network access or dependencies, "
"then verify installed package metadata against the frozen plan."
),
command="python -m pip install --no-index --no-deps <candidate wheels>",
cwd=dashboard.meta_root,
status="planned",
)
)
return tuple(steps)
@@ -0,0 +1,387 @@
"""Deterministic, bounded updates for repository-owned version metadata."""
from __future__ import annotations
import ast
from dataclasses import dataclass
import json
import os
from pathlib import Path
import re
import tempfile
import tomllib
MAX_VERSION_FILES = 64
MAX_VERSION_FILE_BYTES = 2 * 1024 * 1024
class VersionMetadataError(RuntimeError):
"""Raised when release version metadata cannot be changed safely."""
@dataclass(frozen=True, slots=True)
class VersionFileMutation:
path: str
before: bytes
after: bytes
def version_metadata_mutations(
repo_path: Path,
*,
target_version: str,
) -> tuple[VersionFileMutation, ...]:
"""Render all recognized repository version files without writing them."""
version = target_version.removeprefix("v")
candidates: list[tuple[Path, str]] = []
if (repo_path / "pyproject.toml").is_file():
candidates.append((repo_path / "pyproject.toml", "pyproject"))
for package_name, lock_name in (
("package.json", "package-lock.json"),
("webui/package.json", "webui/package-lock.json"),
("webui/package.release.json", "webui/package-lock.release.json"),
):
package = repo_path / package_name
if not package.is_file():
continue
candidates.append((package, "package"))
lock = repo_path / lock_name
if lock.is_file():
candidates.append((lock, "lock"))
src = repo_path / "src"
if src.is_dir():
candidates.extend(
(path, "manifest")
for path in sorted(src.glob("**/backend/manifest.py"))
if path.is_file()
)
candidates.extend(
(path, "package_init")
for path in sorted(src.glob("*/__init__.py"))
if path.is_file()
)
if len(candidates) > MAX_VERSION_FILES:
raise VersionMetadataError("Repository version metadata exceeds its file bound.")
mutations: list[VersionFileMutation] = []
declarations = 0
for path, kind in candidates:
before = _read_bounded(path)
if kind == "pyproject":
after, found = _render_pyproject(before, version=version)
elif kind == "package":
after, found = _render_package_json(before, version=version)
elif kind == "lock":
after, found = _render_package_lock(before, version=version)
elif kind == "manifest":
after, found = _render_python_version(
before,
path=path,
target_name="ModuleManifest",
keyword_name="version",
version=version,
)
else:
after, found = _render_python_assignment(
before,
path=path,
assignment_name="__version__",
version=version,
)
if not found:
continue
declarations += 1
if before != after:
mutations.append(
VersionFileMutation(
path=path.relative_to(repo_path).as_posix(),
before=before,
after=after,
)
)
if declarations == 0:
raise VersionMetadataError(
"Repository has no supported version declaration to update."
)
return tuple(mutations)
def apply_version_metadata_mutations(
repo_path: Path,
*,
target_version: str,
) -> tuple[str, ...]:
"""Apply one deterministic version update, rolling back on write failure."""
mutations = version_metadata_mutations(
repo_path,
target_version=target_version,
)
written: list[VersionFileMutation] = []
try:
for mutation in mutations:
destination = repo_path / mutation.path
if _read_bounded(destination) != mutation.before:
raise VersionMetadataError(
f"Version metadata changed before update: {mutation.path}"
)
_atomic_write(destination, mutation.after)
written.append(mutation)
except Exception:
for mutation in reversed(written):
_atomic_write(repo_path / mutation.path, mutation.before)
raise
return tuple(mutation.path for mutation in mutations)
def version_metadata_paths(
repo_path: Path,
*,
target_version: str,
) -> tuple[str, ...]:
"""Return the exact paths a deterministic target-version update owns."""
return tuple(
mutation.path
for mutation in version_metadata_mutations(
repo_path,
target_version=target_version,
)
)
def _read_bounded(path: Path) -> bytes:
try:
metadata = path.lstat()
except OSError as exc:
raise VersionMetadataError(
f"Version metadata is unavailable: {path.name}"
) from exc
if not path.is_file() or path.is_symlink():
raise VersionMetadataError(
f"Version metadata must be a regular file: {path.name}"
)
if metadata.st_size > MAX_VERSION_FILE_BYTES:
raise VersionMetadataError(
f"Version metadata exceeds its size bound: {path.name}"
)
try:
return path.read_bytes()
except OSError as exc:
raise VersionMetadataError(
f"Version metadata could not be read: {path.name}"
) from exc
def _decode(payload: bytes, *, path: Path | None = None) -> str:
try:
return payload.decode("utf-8")
except UnicodeDecodeError as exc:
label = path.name if path is not None else "file"
raise VersionMetadataError(
f"Version metadata is not UTF-8: {label}"
) from exc
def _render_pyproject(payload: bytes, *, version: str) -> tuple[bytes, bool]:
text = _decode(payload)
try:
parsed = tomllib.loads(text)
except tomllib.TOMLDecodeError as exc:
raise VersionMetadataError("pyproject.toml is malformed.") from exc
project = parsed.get("project")
if not isinstance(project, dict) or not isinstance(project.get("version"), str):
return payload, False
section = re.search(
r"(?ms)^\[project\][^\n]*\n(?P<body>.*?)(?=^\[[^\n]+\]|\Z)",
text,
)
if section is None:
raise VersionMetadataError("pyproject.toml has no editable [project] block.")
body = section.group("body")
match = re.search(
r'(?m)^(?P<prefix>\s*version\s*=\s*)(?P<quote>["\'])(?P<value>[^"\']+)(?P=quote)(?P<suffix>\s*(?:#.*)?)$',
body,
)
if match is None:
raise VersionMetadataError(
"pyproject.toml project.version is not a supported literal."
)
start = section.start("body") + match.start("value")
end = section.start("body") + match.end("value")
rendered = f"{text[:start]}{version}{text[end:]}"
tomllib.loads(rendered)
return rendered.encode("utf-8"), True
def _render_package_json(payload: bytes, *, version: str) -> tuple[bytes, bool]:
data = _json_object(payload, label="package metadata")
if not isinstance(data.get("version"), str):
return payload, False
data["version"] = version
return _json_bytes(data), True
def _render_package_lock(payload: bytes, *, version: str) -> tuple[bytes, bool]:
data = _json_object(payload, label="package lock")
found = False
if isinstance(data.get("version"), str):
data["version"] = version
found = True
packages = data.get("packages")
root = packages.get("") if isinstance(packages, dict) else None
if isinstance(root, dict) and isinstance(root.get("version"), str):
root["version"] = version
found = True
return (_json_bytes(data), True) if found else (payload, False)
def _json_object(payload: bytes, *, label: str) -> dict[str, object]:
try:
value = json.loads(_decode(payload))
except json.JSONDecodeError as exc:
raise VersionMetadataError(f"{label.capitalize()} is malformed.") from exc
if not isinstance(value, dict):
raise VersionMetadataError(f"{label.capitalize()} must be an object.")
return value
def _json_bytes(value: dict[str, object]) -> bytes:
return (
json.dumps(value, indent=2, ensure_ascii=True, separators=(",", ": "))
+ "\n"
).encode("utf-8")
def _render_python_version(
payload: bytes,
*,
path: Path,
target_name: str,
keyword_name: str,
version: str,
) -> tuple[bytes, bool]:
text = _decode(payload, path=path)
try:
tree = ast.parse(text, filename=str(path))
except SyntaxError as exc:
raise VersionMetadataError(f"Python metadata is malformed: {path.name}") from exc
values: list[ast.Constant] = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or _call_name(node.func) != target_name:
continue
for keyword in node.keywords:
if (
keyword.arg == keyword_name
and isinstance(keyword.value, ast.Constant)
and isinstance(keyword.value.value, str)
):
values.append(keyword.value)
if not values:
return payload, False
if len(values) != 1:
raise VersionMetadataError(
f"Python metadata has multiple {target_name}.{keyword_name} values: {path.name}"
)
return _replace_python_literal(text, values[0], version=version, path=path), True
def _render_python_assignment(
payload: bytes,
*,
path: Path,
assignment_name: str,
version: str,
) -> tuple[bytes, bool]:
text = _decode(payload, path=path)
try:
tree = ast.parse(text, filename=str(path))
except SyntaxError as exc:
raise VersionMetadataError(f"Python metadata is malformed: {path.name}") from exc
values: list[ast.Constant] = []
for node in tree.body:
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
continue
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
value = node.value
if (
any(
isinstance(target, ast.Name) and target.id == assignment_name
for target in targets
)
and isinstance(value, ast.Constant)
and isinstance(value.value, str)
):
values.append(value)
if not values:
return payload, False
if len(values) != 1:
raise VersionMetadataError(
f"Python metadata has multiple {assignment_name} assignments: {path.name}"
)
return _replace_python_literal(text, values[0], version=version, path=path), True
def _replace_python_literal(
text: str,
node: ast.Constant,
*,
version: str,
path: Path,
) -> bytes:
if (
node.lineno != node.end_lineno
or node.end_col_offset is None
or node.col_offset < 0
):
raise VersionMetadataError(
f"Python version declaration must be a single-line literal: {path.name}"
)
lines = text.splitlines(keepends=True)
line = lines[node.lineno - 1]
lines[node.lineno - 1] = (
line[: node.col_offset]
+ json.dumps(version)
+ line[node.end_col_offset :]
)
rendered = "".join(lines)
ast.parse(rendered, filename=str(path))
return rendered.encode("utf-8")
def _call_name(node: ast.expr) -> str | None:
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
return node.attr
return None
def _atomic_write(path: Path, payload: bytes) -> None:
metadata = path.stat()
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.",
dir=path.parent,
)
temporary = Path(temporary_name)
try:
os.fchmod(descriptor, metadata.st_mode & 0o777)
with os.fdopen(descriptor, "wb") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
temporary.replace(path)
directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(directory)
finally:
os.close(directory)
except Exception:
try:
os.close(descriptor)
except OSError:
pass
temporary.unlink(missing_ok=True)
raise
+39 -2
View File
@@ -170,18 +170,30 @@ def owner_for_versions_dir(versions_dir: Path) -> str:
def parse_migration_file(owner: str, path: Path) -> Migration | None:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
values: dict[str, Any] = {}
wrapped: Migration | None = None
release_peer = path.parent.parent / "versions" / path.name
if path.parent.name == "dev_versions" and release_peer.is_file():
wrapped = parse_migration_file(owner, release_peer)
for statement in tree.body:
if isinstance(statement, ast.Assign):
for target in statement.targets:
if isinstance(target, ast.Name) and target.id in {"revision", "down_revision", "depends_on", "branch_labels"}:
values[target.id] = ast.literal_eval(statement.value)
values[target.id] = _migration_assignment_value(
target.id,
statement.value,
wrapped=wrapped,
)
elif (
isinstance(statement, ast.AnnAssign)
and isinstance(statement.target, ast.Name)
and statement.target.id in {"revision", "down_revision", "depends_on", "branch_labels"}
and statement.value is not None
):
values[statement.target.id] = ast.literal_eval(statement.value)
values[statement.target.id] = _migration_assignment_value(
statement.target.id,
statement.value,
wrapped=wrapped,
)
revision = values.get("revision")
if not isinstance(revision, str):
return None
@@ -195,6 +207,31 @@ def parse_migration_file(owner: str, path: Path) -> Migration | None:
)
def _migration_assignment_value(
name: str,
value: ast.expr,
*,
wrapped: Migration | None,
) -> Any:
try:
return ast.literal_eval(value)
except (ValueError, TypeError):
if (
wrapped is None
or not isinstance(value, ast.Attribute)
or not isinstance(value.value, ast.Name)
or value.value.id != "_migration"
or value.attr != name
):
return None
return {
"revision": wrapped.revision,
"down_revision": wrapped.down_revisions,
"depends_on": wrapped.depends_on,
"branch_labels": wrapped.branch_labels,
}[name]
def _normalize_revision_tuple(value: Any) -> tuple[str, ...]:
if value is None:
return ()
+51 -15
View File
@@ -45,7 +45,9 @@ from govoplan_release.release_execution import (
verify_catalog_publication_precondition,
verify_repository_preflight_binding,
verify_repository_step_precondition,
verify_release_alignment,
verify_release_runtime_binding,
verify_candidate_installation,
)
from govoplan_release.release_run import (
ReleaseRunConflict,
@@ -532,7 +534,13 @@ def create_app(
) == "interrupted":
plan_step = release_run_plan_step(run, step_id)
spec = executor_spec(plan_step)
if spec is not None and spec.kind in {"tag", "push"}:
if spec is not None and spec.kind in {
"version",
"commit",
"core_bundle",
"tag",
"push",
}:
repo = str(plan_step["repo"])
receipt = reconciled_repository_receipt(
spec=spec,
@@ -542,6 +550,7 @@ def create_app(
expected_receipt=preceding_repository_receipt(
run, step_id=step_id, repo=repo
),
repo_versions=run["immutable"]["input"]["repo_versions"],
)
return release_run_view(
app.state.release_runs.reconcile_step(
@@ -637,7 +646,15 @@ def create_app(
plan_step=plan_step,
workspace_root=app.state.workspace_root,
)
if spec.kind in {"preflight", "tag", "push"}:
repository_kinds = {
"preflight",
"version",
"commit",
"core_bundle",
"tag",
"push",
}
if spec.kind in repository_kinds:
repo = str(plan_step["repo"])
version = run["immutable"]["input"]["repo_versions"][repo]
expected_repository_receipt = preceding_repository_receipt(
@@ -659,7 +676,21 @@ def create_app(
)
receipt = None
if spec.kind in {"preflight", "tag", "push"}:
source_receipts = {
repo: source_receipt
for repo in run["immutable"]["input"]["repo_versions"]
if isinstance(
(
source_receipt := preceding_repository_receipt(
run,
step_id=step_id,
repo=repo,
)
),
dict,
)
}
if spec.kind in repository_kinds:
result, receipt = execute_repository_step(
spec=spec,
plan_step=plan_step,
@@ -667,6 +698,13 @@ def create_app(
workspace_root=app.state.workspace_root,
remote=DURABLE_REMOTE,
expected_receipt=expected_repository_receipt,
source_receipts=source_receipts,
)
elif spec.kind == "alignment":
result = verify_release_alignment(
repo_versions=run["immutable"]["input"]["repo_versions"],
workspace_root=app.state.workspace_root,
source_receipts=source_receipts,
)
elif spec.kind == "catalog_generate":
attempt_fingerprint = (
@@ -690,18 +728,7 @@ def create_app(
signing_keys=signing_keys,
remote=DURABLE_REMOTE,
check_public=run["immutable"]["input"]["public_catalog"],
source_receipts={
repo: receipt
for repo in run["immutable"]["input"]["repo_versions"]
if isinstance(
(
receipt := preceding_repository_receipt(
run, step_id=step_id, repo=repo
)
),
dict,
)
},
source_receipts=source_receipts,
base_catalog=base_catalog,
base_keyring=base_keyring,
)
@@ -738,6 +765,15 @@ def create_app(
"Published candidate changed while its effect was in flight; "
"reconcile the immutable website commit and remote tag."
) from exc
elif spec.kind == "install_verify":
candidate_path = verified_run_candidate(
run,
candidate_root=app.state.release_candidate_root,
)
result = verify_candidate_installation(
candidate_path=candidate_path,
repo_versions=run["immutable"]["input"]["repo_versions"],
)
else:
raise ReleaseRunConflict("Release executor mapping is incomplete.")
+17 -12
View File
@@ -943,10 +943,10 @@
<section id="installVerificationSection">
<div class="section-head">
<h2>5. Installation Verification</h2>
<small>external gate</small>
<small>receipt-bound local gate</small>
</div>
<div class="details">
<p class="hint">The signed catalog and Python artifacts are produced by the durable candidate step. End-to-end installation verification still runs in release integration CI and is not yet a bounded local-console executor.</p>
<p class="hint">The durable verification step installs every selected candidate wheel into a private temporary target with network and dependency resolution disabled, then compares installed package metadata with the frozen release plan. Deployment-specific integration checks remain part of CI.</p>
</div>
</section>
</div>
@@ -1783,7 +1783,7 @@
target: "releaseRunSection",
run,
plan,
match: (step) => /:(preflight|tag|push)$/.test(step.id || ""),
match: (step) => /:(preflight|version|commit|bundle|tag|push)$/.test(step.id || "") || step.id === "release:alignment",
waitingStatus: "create run",
}),
runWorkflowPhase({
@@ -1804,13 +1804,15 @@
match: (step) => step.id === "catalog:validate-sign-publish",
waitingStatus: "waiting",
}),
{
runWorkflowPhase({
id: "verify",
label: "Verify",
target: "installVerificationSection",
state: run?.state?.status === "completed" ? "unavailable" : "locked",
status: run?.state?.status === "completed" ? "CI gate" : "waiting",
},
run,
plan,
match: (step) => step.id === "release:install-verify",
waitingStatus: "waiting",
}),
];
const firstOpen = phases.findIndex((phase) => ["current", "blocked", "unavailable"].includes(phase.state));
if (firstOpen >= 0) {
@@ -1924,10 +1926,10 @@
};
} else if (run.state?.status === "completed") {
guidance = {
title: "Run installation verification",
detail: "Every durable source, package, signing, and publication step has a recorded success receipt.",
remediation: "End-to-end installation verification is still an external release-integration CI gate.",
button: "Review verification gate",
title: "Release run completed",
detail: "Every durable source, package, signing, publication, and local installation-verification step has a recorded success.",
remediation: "Review the run receipts and continue with deployment-specific integration checks where required.",
button: "Review completed run",
action: { type: "scroll", target: "installVerificationSection" },
};
} else {
@@ -2389,9 +2391,12 @@
const blockers = unit.blockers.length ? `<p>${escapeHtml(unit.blockers.join("; "))}</p>` : "";
const warnings = unit.warnings.length ? `<p>${escapeHtml(unit.warnings.join("; "))}</p>` : "";
const kind = unit.status === "blocked" ? "block" : unit.status === "attention" ? "warn" : "ok";
const capabilities = Array.isArray(unit.capabilities) && unit.capabilities.length
? `<p class="action-meta">Capabilities: ${escapeHtml(unit.capabilities.join(", "))}</p>`
: "";
return `<div class="action">
<h3>${pill(unit.status, kind)} ${escapeHtml(unit.repo)} ${escapeHtml(unit.current_version || "-")} -> ${escapeHtml(unit.target_version)}</h3>
${blockers}${warnings}
${capabilities}${blockers}${warnings}
</div>`;
}).join("");
const compatibilityHtml = compatibility.map((issue) => {