Harden source release preparation and record verified security follow-up
Dependency Audit / dependency-audit (push) Successful in 1m51s
Deployment Installer / deployment-installer (push) Successful in 8s
Security Audit / security-audit (push) Successful in 12m32s

This commit is contained in:
2026-09-08 08:04:12 +02:00
parent 9554657bb5
commit 58d320d9b3
26 changed files with 3833 additions and 163 deletions
+31 -1
View File
@@ -11,7 +11,7 @@ import tomllib
from .contracts import parse_manifest_contract
from .model import RepositorySnapshot, RepositorySpec, VersionSnapshot
from .workspace import resolve_repo_path
from .workspace import load_repository_specs, resolve_repo_path
def collect_repository_snapshot(
@@ -98,6 +98,7 @@ def collect_repository_snapshot(
def collect_versions(path: Path) -> VersionSnapshot:
return VersionSnapshot(
pyproject=read_pyproject_version(path),
developer_meta=read_developer_meta_version(path),
package=read_json_version(path / "package.json"),
webui_package=read_json_version(path / "webui" / "package.json"),
manifests=read_manifest_versions(path),
@@ -105,6 +106,35 @@ def collect_versions(path: Path) -> VersionSnapshot:
)
def registered_developer_meta_path(path: Path) -> Path | None:
"""Recognize only the catalog's explicit Meta support-repository identity.
This is metadata discovery, not authorization to access a remote or mutate
a checkout. Tagging applies its separate registered source trust contract.
"""
for spec in load_repository_specs(include_website=False):
if (
spec.name == "govoplan"
and spec.category == "system"
and spec.subtype == "meta"
and path.absolute() == resolve_repo_path(spec, path.parent).absolute()
):
return path / "packages" / "govoplan-meta" / "pyproject.toml"
return None
def read_developer_meta_version(path: Path) -> str | None:
package = registered_developer_meta_path(path)
if package is None or not package.is_file():
return None
with package.open("rb") as handle:
project = tomllib.load(handle).get("project")
if isinstance(project, dict) and project.get("name") == "govoplan":
version = project.get("version")
return version if isinstance(version, str) else None
return None
def read_pyproject_version(path: Path) -> str | None:
pyproject = path / "pyproject.toml"
if not pyproject.exists():
@@ -0,0 +1,380 @@
"""Receipt-bound, out-of-run preparation of the real developer meta-package.
This deliberately does not commit, tag, publish, or update a running release
console. The complete generated file is reviewed in a separate source checkout.
"""
from __future__ import annotations
import copy
import hashlib
import os
from pathlib import Path
import runpy
import stat
import tempfile
import tomllib
from .git_state import (
collect_repository_snapshot,
git,
git_text,
registered_developer_meta_path,
)
from .repository_tag import normalize_version, remote_tag_commit, run
from .source_provenance import registered_source_origin_issues
from .source_tag_batch import (
_OBJECT,
_owned_path,
_source_filesystem,
_tracked_worktree,
_trusted_ancestry,
)
from .workspace import META_ROOT, load_repository_specs, resolve_repo_path
PACKAGE = "packages/govoplan-meta/pyproject.toml"
MAX_INPUT_FILES = 128
MAX_INPUT_BYTES = 2 * 1024 * 1024
MAX_TOTAL_BYTES = 16 * 1024 * 1024
GENERATOR = ".operator/generate-developer-meta-package.py"
class MetaPreparationError(ValueError):
"""Preparation is blocked, or an applied file needs explicit reconciliation."""
class MetaPreparationAmbiguous(MetaPreparationError):
"""The file effect may have happened and requires explicit reconciliation."""
def preparation_command(*, workspace: Path, target_version: str) -> str:
import shlex
return " ".join(
shlex.quote(value)
for value in (
"python",
str(META_ROOT / "tools/release/prepare-developer-meta-package.py"),
"--workspace",
str(workspace),
"--target-version",
target_version,
)
)
def _read_input(path: Path) -> tuple[bytes, dict]:
def identity(value):
return (
value.st_dev,
value.st_ino,
value.st_uid,
value.st_gid,
value.st_mode,
value.st_size,
value.st_mtime_ns,
value.st_ctime_ns,
)
before = path.lstat()
if (
not stat.S_ISREG(before.st_mode)
or before.st_uid != os.geteuid()
or before.st_mode & 0o022
or not 0 < before.st_size <= MAX_INPUT_BYTES
):
raise MetaPreparationError(
"Preparation inputs must be owned, bounded regular files."
)
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(descriptor, "rb") as source:
opened = os.fstat(source.fileno())
if identity(opened) != identity(before):
raise MetaPreparationError("Preparation input changed before reading.")
payload = source.read(before.st_size + 1)
if (
identity(os.fstat(source.fileno())) != identity(opened)
or len(payload) != before.st_size
):
raise MetaPreparationError("Preparation input changed while reading.")
return payload, {
"sha256": hashlib.sha256(payload).hexdigest(),
"identity": [
before.st_dev,
before.st_ino,
before.st_uid,
before.st_gid,
stat.S_IMODE(before.st_mode),
before.st_size,
],
}
def _snapshot(*, repo_path: Path, target_version: str, output_dirty: bool = False):
workspace = repo_path.parent
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
if registered_developer_meta_path(repo_path) != repo_path / PACKAGE:
raise MetaPreparationError(
"Only the registered Meta nested-package identity can be prepared."
)
if repo_path.resolve() == META_ROOT.resolve():
raise MetaPreparationError(
"Prepare a separate registered source checkout, never the running operator tooling."
)
paths = [repo_path / PACKAGE, repo_path / "requirements-release.txt"]
for path in workspace.glob("govoplan-*/pyproject.toml"):
paths.append(path)
if len(paths) > MAX_INPUT_FILES:
raise MetaPreparationError(
"Developer composition exceeds its input-file bound."
)
if workspace / "govoplan-core/pyproject.toml" not in paths:
raise MetaPreparationError(
"Prepare and commit matching Core metadata before Meta preparation."
)
repositories = {"govoplan": repo_path}
for path in paths[2:]:
name = path.parent.name
if (
name not in specs
or resolve_repo_path(specs[name], workspace) != path.parent
):
raise MetaPreparationError(
"Developer composition contains an unregistered package checkout."
)
repositories[name] = path.parent
filesystems = {}
for name, path in repositories.items():
filesystems[name] = _source_filesystem(path=path, workspace=workspace)
_tracked_worktree(path)
issues = registered_source_origin_issues(
repo_versions={name: target_version for name in repositories},
workspace=workspace,
remote="origin",
)
if issues:
raise MetaPreparationError(
"Preparation source origins do not match the registered repositories."
)
sources = {}
for name, path in sorted(repositories.items()):
snapshot = collect_repository_snapshot(
specs[name],
workspace_root=workspace,
target_tag=None,
online=False,
)
dirty_allowed = (
output_dirty
and name == "govoplan"
and snapshot.dirty_entries == (f" M {PACKAGE}",)
)
if (
snapshot.errors
or not snapshot.has_head
or snapshot.branch != "main"
or snapshot.upstream != "origin/main"
or snapshot.behind
or (snapshot.dirty and not dirty_allowed)
):
raise MetaPreparationError(
"Preparation requires reviewed clean main sources tracking origin/main."
)
head = git_text(path, "rev-parse", "--verify", "HEAD")
live = run(
("git", "ls-remote", "--exit-code", "--heads", "origin", "refs/heads/main"),
cwd=path,
)
lines = live.stdout.strip().splitlines()
if live.returncode or len(lines) != 1:
raise MetaPreparationError("Could not verify live preparation source main.")
remote_main, separator, reference = lines[0].partition("\t")
if (
not _OBJECT.fullmatch(head)
or not _OBJECT.fullmatch(remote_main)
or not separator
or reference != "refs/heads/main"
or git(path, "merge-base", "--is-ancestor", remote_main, head).returncode
):
raise MetaPreparationError(
"Preparation source main diverged; fetch and review separately."
)
sources[name] = {
"head": head,
"remote_main": remote_main,
"filesystem": filesystems[name],
}
tag = f"v{target_version}"
published = remote_tag_commit(repo_path, remote="origin", tag=tag)
if (
published.error
or published.tag_object
or git_text(repo_path, "rev-parse", "--verify", f"refs/tags/{tag}")
):
raise MetaPreparationError(
"An existing or unverifiable target Meta tag blocks source preparation."
)
inputs, payloads = {}, {}
total = 0
for path in sorted(paths):
payload, identity = _read_input(path)
total += len(payload)
if total > MAX_TOTAL_BYTES:
raise MetaPreparationError(
"Developer composition exceeds its aggregate input bound."
)
relative = path.relative_to(workspace).as_posix()
inputs[relative], payloads[relative] = identity, payload
generator_path = META_ROOT / "tools/release/generate-developer-meta-package.py"
_trusted_ancestry(generator_path.parent)
_owned_path(META_ROOT, directory=True)
generator, generator_identity = _read_input(generator_path)
if total + len(generator) > MAX_TOTAL_BYTES:
raise MetaPreparationError(
"Developer composition exceeds its aggregate input bound."
)
payloads[GENERATOR] = generator
current = tomllib.loads(payloads[f"govoplan/{PACKAGE}"].decode("utf-8")).get(
"project", {}
)
core = tomllib.loads(payloads["govoplan-core/pyproject.toml"].decode("utf-8")).get(
"project", {}
)
if (
not isinstance(current, dict)
or current.get("name") != "govoplan"
or not isinstance(current.get("version"), str)
):
raise MetaPreparationError(
"Nested developer-package identity and version must be exact."
)
if (
not isinstance(core, dict)
or core.get("name") != "govoplan-core"
or core.get("version") != target_version
):
raise MetaPreparationError(
"Prepare and commit Core at the requested target version before Meta preparation."
)
from .version_alignment import repository_version_issues
if repository_version_issues(
workspace / "govoplan-core", expected_version=target_version
):
raise MetaPreparationError(
"Prepare aligned Core version metadata before Meta preparation."
)
receipt = {
"kind": "developer_meta_preparation_v1",
"workspace": str(workspace),
"target_version": target_version,
"sources": sources,
"inputs": inputs,
"operator_generator": generator_identity,
}
return receipt, payloads
def _render(payloads: dict[str, bytes]) -> bytes:
# The trusted operator generator sees only the frozen bounded data snapshot.
with tempfile.TemporaryDirectory(prefix="govoplan-meta-render-") as temporary:
workspace = Path(temporary)
for relative, payload in payloads.items():
path = workspace / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(payload)
render = runpy.run_path(str(workspace / GENERATOR))["render"]
result = render(
workspace=workspace,
requirements=workspace / "govoplan/requirements-release.txt",
).encode("utf-8")
if len(result) > MAX_INPUT_BYTES:
raise MetaPreparationError(
"Generated developer package exceeds its output bound."
)
return result
def preview_meta_mutation(*, repo_path: Path, target_version: str):
version = normalize_version(target_version)
if not version:
raise MetaPreparationError("A valid target release version is required.")
receipt, payloads = _snapshot(repo_path=repo_path, target_version=version)
after = _render(payloads)
observed, _ = _snapshot(repo_path=repo_path, target_version=version)
if observed != receipt:
raise MetaPreparationError(
"Developer preparation inputs changed during preview."
)
receipt["output_sha256"] = hashlib.sha256(after).hexdigest()
return receipt, payloads[f"govoplan/{PACKAGE}"], after
def prepare_developer_meta_package(
*,
repo_path: Path,
target_version: str,
apply: bool = False,
expected_receipt=None,
confirm_out_of_run: bool = False,
) -> dict:
"""Preview or explicitly apply one full generated file; never commit/publish."""
try:
receipt, before, after = preview_meta_mutation(
repo_path=repo_path, target_version=target_version
)
result = {
"status": "planned" if before != after else "noop",
"path": PACKAGE,
"receipt": receipt,
"after_sha256": hashlib.sha256(after).hexdigest(),
"changed": before != after,
}
if not apply:
return result
if not confirm_out_of_run:
raise MetaPreparationError(
"Explicitly confirm that no durable run is active for this source workspace."
)
if receipt != expected_receipt:
raise MetaPreparationError(
"Preparation inputs changed since the reviewed preview; create a fresh preview."
)
if before == after:
return result
from .version_metadata import _atomic_write
source_receipt = {
key: value for key, value in receipt.items() if key != "output_sha256"
}
observed, _ = _snapshot(
repo_path=repo_path, target_version=receipt["target_version"]
)
if observed != source_receipt:
raise MetaPreparationError(
"Preparation inputs changed before the file effect; create a fresh preview."
)
try:
_atomic_write(repo_path / PACKAGE, after)
observed, payloads = _snapshot(
repo_path=repo_path,
target_version=receipt["target_version"],
output_dirty=True,
)
comparison = copy.deepcopy(observed)
output = f"govoplan/{PACKAGE}"
comparison["inputs"][output] = receipt["inputs"][output]
if comparison != source_receipt or payloads[output] != after:
raise MetaPreparationError("Preparation inputs changed after writing.")
except Exception as exc:
raise MetaPreparationAmbiguous(
"Generated file may have been written but its write/post-check failed; "
"review the delta and reconcile manually."
) from exc
return {**result, "status": "prepared", "after_receipt": observed}
except MetaPreparationError:
raise
except (OSError, UnicodeError, ValueError, KeyError, TypeError) as exc:
raise MetaPreparationError(
f"Developer preparation failed closed ({type(exc).__name__})."
) from exc
+2
View File
@@ -23,6 +23,7 @@ class RepositorySpec:
@dataclass(frozen=True, slots=True)
class VersionSnapshot:
pyproject: str | None = None
developer_meta: str | None = None
package: str | None = None
webui_package: str | None = None
manifests: tuple[str, ...] = ()
@@ -32,6 +33,7 @@ class VersionSnapshot:
def primary(self) -> str | None:
return (
self.pyproject
or self.developer_meta
or self.package
or self.webui_package
or (self.manifests[0] if self.manifests else None)
+26 -157
View File
@@ -34,7 +34,24 @@ def tag_repositories(
apply: bool = False, # noqa: A002 - mirrors API field.
push: bool = False,
) -> dict[str, object]:
"""Create annotated tags and optionally publish branch and tag atomically.
from .source_tag_batch import tag_source_batch
return tag_source_batch(
repos=repos, repo_versions=repo_versions, workspace_root=workspace_root,
remote=remote, message=message, apply=apply, push=push,
)
def _preview_repositories(
*,
repos: tuple[str, ...],
repo_versions: dict[str, str],
workspace_root: Path | str | None = None,
remote: str = "origin",
message: str | None = None,
push: bool = False,
) -> dict[str, object]:
"""Read-only shared manifest/version/composition/tag preflight.
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
@@ -64,42 +81,7 @@ def tag_repositories(
f"{issue.source}={issue.actual!r}, expected {issue.expected!r} ({issue.message})"
)
if apply:
preflight = tag_repositories(
repos=selected,
repo_versions=repo_versions,
workspace_root=workspace,
remote=remote,
message=message,
apply=False,
push=push,
)
preflight_rows = preflight.get("repositories")
if isinstance(preflight_rows, list) and any(
isinstance(item, dict) and item.get("status") in {"blocked", "failed"}
for item in preflight_rows
):
blocked_rows = []
for item in preflight_rows:
if not isinstance(item, dict) or item.get("status") in {"blocked", "failed"}:
blocked_rows.append(item)
continue
blocked_rows.append(
{
**item,
"status": "skipped",
"detail": "preflight passed, but no release tag was changed because another selected repository is blocked",
}
)
return {
"status": "blocked",
"apply": True,
"push": push,
"remote": remote,
"detail": "batch preflight failed; no selected repository was mutated",
"repositories": blocked_rows,
}
elif selected:
if selected:
manifest_gate_issue = manifest_shape_gate_issue(workspace)
if manifest_gate_issue:
return {
@@ -255,130 +237,17 @@ def tag_repositories(
"remote_tag_object": remote_result.tag_object,
}
)
if not apply:
detail = preview_detail(tag=tag, local_commit=local_commit, remote_commit=remote_result.commit, push=push)
status = "noop" if remote_result.commit or (local_commit and not push) else "planned"
results.append({**row, "status": status, "detail": detail})
continue
if remote_result.commit:
if not local_commit:
fetch_result = run(("git", "fetch", remote, f"refs/tags/{tag}:refs/tags/{tag}"), cwd=path)
if fetch_result.returncode != 0:
results.append(
{
**row,
"status": "failed",
"detail": f"remote tag {tag} exists at HEAD but could not be fetched locally",
"returncode": fetch_result.returncode,
"stdout": compact_output(fetch_result.stdout),
"stderr": compact_output(fetch_result.stderr),
}
)
continue
results.append(
{
**row,
"status": "published",
"detail": f"immutable tag {tag} is already published at HEAD",
"after_local_tag_commit": head_commit,
"after_remote_tag_commit": head_commit,
}
)
continue
created = False
if not local_commit:
create_result = run(create_command, cwd=path)
if create_result.returncode != 0:
results.append(
{
**row,
"status": "failed",
"detail": f"could not create annotated tag {tag}",
"returncode": create_result.returncode,
"stdout": compact_output(create_result.stdout),
"stderr": compact_output(create_result.stderr),
}
)
continue
created = True
if not push:
results.append(
{
**row,
"status": "tagged" if created else "noop",
"detail": f"created annotated tag {tag} at HEAD" if created else f"annotated tag {tag} already exists at HEAD",
"after_local_tag_commit": head_commit,
}
)
continue
publish_result = run(publish_command, cwd=path)
if publish_result.returncode != 0:
results.append(
{
**row,
"status": "failed",
"detail": f"created local tag {tag}, but atomic branch and tag publication failed" if created else f"atomic branch and tag publication failed for {tag}",
"returncode": publish_result.returncode,
"after_local_tag_commit": head_commit,
"stdout": compact_output(publish_result.stdout),
"stderr": compact_output(publish_result.stderr),
}
)
continue
after_local_object = git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}")
after_remote = remote_tag_commit(path, remote=remote, tag=tag)
if (
after_remote.error
or not after_remote.annotated
or after_remote.commit != head_commit
or after_remote.tag_object != after_local_object
):
verification_detail = after_remote.error or "remote tag did not resolve to the published annotated tag object at HEAD"
results.append(
{
**row,
"status": "failed",
"detail": f"Git push returned success, but the remote release-tag postcondition failed: {verification_detail}",
"returncode": publish_result.returncode,
"after_local_tag_commit": head_commit,
"after_local_tag_object": after_local_object,
"after_remote_tag_commit": after_remote.commit,
"after_remote_tag_object": after_remote.tag_object,
"stdout": compact_output(publish_result.stdout),
"stderr": compact_output(publish_result.stderr),
}
)
continue
results.append(
{
**row,
"status": "published",
"detail": f"published branch {snapshot.branch} and immutable tag {tag} atomically to {remote}",
"returncode": publish_result.returncode,
"after_local_tag_commit": head_commit,
"after_remote_tag_commit": head_commit,
"after_local_tag_object": after_local_object,
"after_remote_tag_object": after_remote.tag_object,
"stdout": compact_output(publish_result.stdout),
"stderr": compact_output(publish_result.stderr),
}
)
detail = preview_detail(tag=tag, local_commit=local_commit, remote_commit=remote_result.commit, push=push)
row_status = "noop" if remote_result.commit or (local_commit and not push) else "planned"
results.append({**row, "status": row_status, "detail": detail})
if any(item["status"] in {"blocked", "failed"} for item in results):
status = "blocked" if not apply else "partial"
elif any(item["status"] == "published" for item in results):
status = "published"
elif any(item["status"] == "tagged" for item in results):
status = "tagged"
result_status = "blocked"
elif any(item["status"] == "planned" for item in results):
status = "planned"
result_status = "planned"
else:
status = "noop"
return {"status": status, "apply": apply, "push": push, "remote": remote, "repositories": results}
result_status = "noop"
return {"status": result_status, "apply": False, "push": push, "remote": remote, "repositories": results}
def normalize_version(value: str | None) -> str:
@@ -8,6 +8,7 @@ from pathlib import Path
import shlex
from .contracts import validate_contracts
from .git_state import registered_developer_meta_path, read_pyproject_version
from .model import (
CompatibilityIssue,
InterfaceProviderSnapshot,
@@ -111,6 +112,39 @@ def apply_repository_version_gate(
version_update_supported_by_repo: dict[str, bool] = {}
deferred_core_lock_repos: set[str] = set()
for unit in units:
if registered_developer_meta_path(workspace / unit.repo) is not None:
from .meta_preparation import preparation_command
try:
core_version = read_pyproject_version(workspace / "govoplan-core")
except (OSError, ValueError, TypeError):
core_version = None
core_ready = core_version == unit.target_version
issues_by_repo.setdefault(unit.repo, []).append(
ReleaseGateFinding(
code="developer_meta_out_of_run" if core_ready else "developer_meta_core_preparation_required",
severity="blocker",
message=(
"Meta is an out-of-run support release, not a self-updating durable executor."
if core_ready else
"Prepare and commit Core at the requested target before regenerating Meta."
),
remediation=(
"Complete Core and module preparation first. Stop active durable runs for this workspace. "
"In a separate trusted source checkout, preview "
+ preparation_command(workspace=workspace, target_version=unit.target_version)
+ ". Review its receipt; apply with --receipt <preview.json> --apply --confirm-out-of-run. "
"Review and commit the whole generated package, publish the matching Core release first, "
"then use guarded Meta source tagging/publication and create a fresh durable run."
),
repo=unit.repo, source="developer meta-package preparation",
expected=unit.target_version, actual=core_version or "missing Core version",
)
)
version_update_supported_by_repo[unit.repo] = False
# Its complete canonical composition remains a publication gate;
# this plan must not claim a generic in-run mutation/commit path.
continue
version_update_supported = unit.current_version == unit.target_version
if unit.current_version and unit.current_version != unit.target_version:
try:
@@ -437,6 +471,7 @@ def build_unit(
value
for value in (
repo.versions.pyproject,
repo.versions.developer_meta,
repo.versions.package,
repo.versions.webui_package,
*repo.versions.manifests,
@@ -572,7 +607,7 @@ def repository_capabilities(
def dependency_ordered_units(
units: tuple[ReleasePlanUnit, ...],
) -> tuple[ReleasePlanUnit, ...]:
"""Order module providers before consumers while keeping Core last."""
"""Order modules before Core, followed by the out-of-run Meta support unit."""
by_repo = {unit.repo: unit for unit in units}
providers: dict[str, set[str]] = {}
@@ -592,8 +627,10 @@ def dependency_ordered_units(
)
if "govoplan-core" in dependencies:
dependencies["govoplan-core"].update(
repo for repo in by_repo if repo != "govoplan-core"
repo for repo in by_repo if repo not in {"govoplan-core", "govoplan"}
)
if "govoplan" in dependencies:
dependencies["govoplan"].update(repo for repo in by_repo if repo != "govoplan")
ordered: list[ReleasePlanUnit] = []
remaining = set(by_repo)
@@ -690,6 +727,8 @@ def dry_run_steps(
*, units: tuple[ReleasePlanUnit, ...], dashboard: ReleaseDashboard, channel: str
) -> tuple[ReleasePlanStep, ...]:
steps: list[ReleasePlanStep] = []
meta_units = tuple(unit for unit in units if unit.repo == "govoplan")
units = tuple(unit for unit in units if unit.repo != "govoplan")
snapshots = {repo.spec.name: repo for repo in dashboard.repositories}
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")
@@ -991,6 +1030,33 @@ def dry_run_steps(
status="planned",
)
)
for unit in meta_units:
from .meta_preparation import preparation_command
steps.extend((
ReleasePlanStep(
id="govoplan:prepare-support",
title="Prepare the complete developer meta-package outside this run",
detail=(
"First prepare and commit Core at the target and review module/requirements inputs. "
"Stop active runs, preview and explicitly apply the frozen composition in a separate "
"source checkout; review and commit manually. No durable self-update is supported."
),
command=preparation_command(workspace=Path(dashboard.workspace_root), target_version=unit.target_version),
cwd=dashboard.meta_root, repo=unit.repo, status="needs-executor",
),
ReleasePlanStep(
id="govoplan:publish-support",
title="Publish the prepared Meta support source after Core",
detail=(
"After the matching Core annotated tag and exact main are published, use the shared "
"guarded Meta tag preview/local-tag/publish route. Commit/push reviewed preparation "
"and create a fresh durable run; do not update the current runtime binding."
),
cwd=dashboard.meta_root, repo=unit.repo, status="needs-executor",
mutating=True,
),
))
return tuple(steps)
@@ -0,0 +1,792 @@
"""Strict registered-source release contract for every source-tag batch.
Meta is not a root Python package. Its nested developer package is released only
after a whole-batch source preflight and the matching immutable Core release.
No selected checkout supplies the developer-package generator or release
validation tooling. The trusted shared checker may load reviewed application
manifests; this is not an untrusted-code sandbox.
"""
from __future__ import annotations
from pathlib import Path
import hashlib
import os
import re
import stat
from .git_state import collect_repository_snapshot, git, git_text
from .repository_tag import (
_preview_repositories,
basic_blocker,
normalize_version,
ref_commit,
remote_tag_commit,
run,
)
from .source_provenance import registered_source_origin_issues
from .version_alignment import (
repository_version_issues,
selected_release_webui_bundle_issues,
selected_webui_repository_names,
)
from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root
_OBJECT = re.compile(r"[0-9a-f]{40}(?:[0-9a-f]{24})?\Z")
class SourceReceiptError(ValueError):
pass
def _owned_path(path, *, directory):
observed = path.lstat()
expected = stat.S_ISDIR if directory else stat.S_ISREG
if stat.S_ISLNK(observed.st_mode) or not expected(observed.st_mode):
raise SourceReceiptError(
"source authority must use real paths, without symlinks or special files"
)
if observed.st_uid != os.geteuid():
raise SourceReceiptError(
"source authority is not owned by the current operator"
)
if observed.st_mode & 0o022:
raise SourceReceiptError("source authority is group/world writable")
return observed
def _trusted_ancestry(path):
# Same ownership/mode policy as the publisher's trust-path guard. A sticky
# shared ancestor such as /tmp may contain an owned, non-writable child;
# the workspace/repository themselves are never given that exception.
for ancestor in (path, *path.parents):
observed = ancestor.lstat()
if stat.S_ISLNK(observed.st_mode) or not stat.S_ISDIR(observed.st_mode):
raise SourceReceiptError(
"source ancestry must contain real directories, not symlinks"
)
if observed.st_uid not in {0, os.geteuid()}:
raise SourceReceiptError("source ancestry has an untrusted owner")
if observed.st_mode & 0o022 and not observed.st_mode & stat.S_ISVTX:
raise SourceReceiptError("source ancestry is group/world writable")
def _git_pointer(path):
_owned_path(path, directory=False)
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(descriptor, "rb") as source:
observed = os.fstat(source.fileno())
if not stat.S_ISREG(observed.st_mode) or not 0 < observed.st_size <= 4096:
raise SourceReceiptError("Git metadata pointer is invalid or oversized")
value = source.read(observed.st_size + 1)
if len(value) != observed.st_size:
raise SourceReceiptError("Git metadata pointer changed during validation")
return value.decode("utf-8").strip()
def _git_tree(root):
pending = [(root, 0)]
count = 0
while pending:
directory, depth = pending.pop()
_owned_path(directory, directory=True)
if depth > 128:
raise SourceReceiptError(
"Git metadata exceeds its trust-validation depth limit"
)
with os.scandir(directory) as entries:
for entry in entries:
count += 1
if count > 500_000:
raise SourceReceiptError(
"Git metadata exceeds its trust-validation entry limit"
)
candidate = Path(entry.path)
observed = candidate.lstat()
if stat.S_ISDIR(observed.st_mode):
pending.append((candidate, depth + 1))
else:
_owned_path(candidate, directory=False)
def _filesystem_identity(path, observed):
return [
str(path),
observed.st_dev,
observed.st_ino,
observed.st_uid,
observed.st_gid,
stat.S_IMODE(observed.st_mode),
]
def _source_filesystem(*, path, workspace):
"""Validate source/Git ownership before invoking even read-only Git."""
if path.absolute() != path.resolve() or not path.resolve().is_relative_to(
workspace.resolve()
):
raise SourceReceiptError(
"source checkout leaves the private workspace or traverses a symlink"
)
_trusted_ancestry(path)
workspace_info = _owned_path(workspace, directory=True)
repo_info = _owned_path(path, directory=True)
marker = path / ".git"
marker_info = marker.lstat()
if stat.S_ISDIR(marker_info.st_mode):
git_dir = marker
else:
value = _git_pointer(marker)
if not value.startswith("gitdir: "):
raise SourceReceiptError("Git worktree pointer is invalid")
git_dir = Path(os.path.abspath(path / value.removeprefix("gitdir: ")))
if git_dir.absolute() != git_dir.resolve() or not git_dir.resolve().is_relative_to(
workspace.resolve()
):
raise SourceReceiptError(
"Git checkout metadata must stay inside the trusted workspace"
)
_trusted_ancestry(git_dir)
_owned_path(git_dir, directory=True)
common_dir = git_dir
common_pointer = git_dir / "commondir"
if common_pointer.exists() or common_pointer.is_symlink():
common_dir = Path(os.path.abspath(git_dir / _git_pointer(common_pointer)))
identities = {
"workspace": _filesystem_identity(workspace, workspace_info),
"checkout": _filesystem_identity(path, repo_info),
}
scanned = set()
for label, directory in (
("git_directory", git_dir),
("git_common_directory", common_dir),
):
if (
directory.absolute() != directory.resolve()
or not directory.resolve().is_relative_to(workspace.resolve())
):
raise SourceReceiptError(
"Git checkout metadata must stay inside the trusted workspace"
)
_trusted_ancestry(directory)
observed = _owned_path(directory, directory=True)
if observed.st_mode & 0o700 != 0o700:
raise SourceReceiptError(
"Git metadata target must be readable and writable by its operator"
)
identities[label] = _filesystem_identity(directory, observed)
if directory not in scanned:
_git_tree(directory)
scanned.add(directory)
identities["git_marker"] = _filesystem_identity(
marker, _owned_path(marker, directory=stat.S_ISDIR(marker_info.st_mode))
)
for candidate in (
common_dir / "objects/info/alternates",
common_dir / "objects/info/http-alternates",
common_dir / "info/grafts",
):
if candidate.exists() or candidate.is_symlink():
raise SourceReceiptError(
"Git object alternates and grafts are not permitted"
)
return identities
def _tracked_worktree(path):
tracked = git(path, "ls-files", "-v", "-z", timeout=30)
if tracked.returncode or len(tracked.stdout) > 16 * 1024 * 1024:
raise SourceReceiptError(
"tracked release inputs exceed their trust-validation limit"
)
checked = {path}
tracked_paths = set()
names = tracked.stdout.split("\0")
if len(names) > 100_001:
raise SourceReceiptError(
"tracked release inputs exceed their trust-validation count limit"
)
for entry in filter(None, names):
# git status deliberately hides assume-unchanged and skip-worktree
# paths. Never validate mutable working metadata and then tag different
# committed bytes because an index flag suppressed the dirty evidence.
if not entry.startswith("H "):
raise SourceReceiptError(
"hidden, sparse or unmerged tracked index entries are not permitted"
)
name = entry[2:]
tracked_paths.add(name)
relative = Path(name)
if relative.is_absolute() or ".." in relative.parts:
raise SourceReceiptError("tracked release input has an unsafe path")
candidate = path / relative
for parent in candidate.parents:
if parent == path:
break
if parent not in checked:
_owned_path(parent, directory=True)
checked.add(parent)
_owned_path(candidate, directory=False)
# Version/composition checks inspect existing files, including ignored
# paths. Their declarations must come from the selected committed source,
# not ignored working bytes absent from the tag's tree.
metadata = [
path / name
for name in (
"pyproject.toml",
"package.json",
"package-lock.json",
"webui/package.json",
"webui/package.release.json",
"webui/package-lock.json",
"webui/package-lock.release.json",
)
]
if path.name == "govoplan":
metadata.extend(
path / name
for name in (
"packages/govoplan-meta/pyproject.toml",
"requirements-release.txt",
)
)
metadata.extend((path / "src").glob("**/backend/manifest.py"))
metadata.extend((path / "src").glob("*/__init__.py"))
for candidate in metadata:
if (candidate.exists() or candidate.is_symlink()) and candidate.relative_to(
path
).as_posix() not in tracked_paths:
raise SourceReceiptError(
"selected release version/composition metadata must be tracked in the frozen source"
)
def _receipt(*, spec, workspace, version, filesystem):
path = resolve_repo_path(spec, workspace)
_tracked_worktree(path)
snapshot = collect_repository_snapshot(
spec, workspace_root=workspace, target_tag=f"v{version}", online=False
)
blocker = basic_blocker(snapshot=snapshot, version=version)
if blocker:
raise SourceReceiptError(blocker)
if (
not snapshot.exists
or not snapshot.is_git
or not snapshot.has_head
or snapshot.errors
or snapshot.safe_directory_required
or snapshot.dirty
or snapshot.branch != "main"
or snapshot.upstream != "origin/main"
or snapshot.behind
):
raise SourceReceiptError(
"requires a clean registered main checkout tracking origin/main, not behind"
)
common = git_text(path, "rev-parse", "--path-format=absolute", "--git-common-dir")
if (
not common
or Path(common).absolute() != Path(common).resolve()
or not Path(common).resolve().is_relative_to(workspace.resolve())
or git_text(path, "rev-parse", "--show-toplevel") != str(path.resolve())
):
raise SourceReceiptError(
"Git checkout metadata must stay inside the trusted workspace"
)
head = git_text(path, "rev-parse", "--verify", "HEAD")
if not _OBJECT.fullmatch(head):
raise SourceReceiptError("source HEAD is not an exact commit")
live = run(
("git", "ls-remote", "--exit-code", "--heads", "origin", "refs/heads/main"),
cwd=path,
)
lines = live.stdout.strip().splitlines()
if live.returncode or len(lines) != 1:
raise SourceReceiptError("could not verify live origin/main")
remote_main, separator, ref = lines[0].partition("\t")
if not separator or ref != "refs/heads/main" or not _OBJECT.fullmatch(remote_main):
raise SourceReceiptError("live origin/main returned an invalid source receipt")
if git(path, "merge-base", "--is-ancestor", remote_main, head).returncode != 0:
raise SourceReceiptError(
"live origin/main is unavailable locally or diverges; fetch and review before retrying"
)
tag = f"v{version}"
local_object = git_text(path, "rev-parse", "--verify", f"refs/tags/{tag}") or None
if local_object:
if git_text(path, "cat-file", "-t", f"refs/tags/{tag}") != "tag":
raise SourceReceiptError("local immutable tag must be annotated")
if ref_commit(path, f"refs/tags/{tag}") != head:
raise SourceReceiptError(
"local immutable tag points to another commit, not HEAD"
)
remote_tag = remote_tag_commit(path, remote="origin", tag=tag)
if remote_tag.error:
raise SourceReceiptError("could not verify the remote release tag")
if remote_tag.tag_object:
if not remote_tag.annotated:
raise SourceReceiptError("remote immutable tag must be annotated")
if remote_tag.commit != head:
raise SourceReceiptError(
"remote immutable tag points to another commit, not HEAD"
)
if local_object and remote_tag.tag_object != local_object:
raise SourceReceiptError(
"local and remote immutable tag annotation objects differ"
)
return {
"head": head,
"branch": "main",
"upstream": "origin/main",
"origin": spec.remote,
"remote_main": remote_main,
"tag": tag,
"local_tag_object": local_object,
"remote_tag_object": remote_tag.tag_object,
"filesystem": filesystem,
}
def _collect_receipts(*, versions, specs, workspace):
filesystems = {}
for repo in versions:
if repo not in specs:
raise SourceReceiptError(f"{repo}: source repository is not registered")
filesystems[repo] = _source_filesystem(
path=resolve_repo_path(specs[repo], workspace), workspace=workspace
)
issues = registered_source_origin_issues(
repo_versions=versions, workspace=workspace, remote="origin"
)
if issues:
raise SourceReceiptError(
"; ".join(f"{issue.repo}: {issue.message}" for issue in issues)
)
receipts = {}
for repo, version in versions.items():
if repo not in specs:
raise SourceReceiptError(f"{repo}: source repository is not registered")
try:
receipts[repo] = _receipt(
spec=specs[repo],
workspace=workspace,
version=version,
filesystem=filesystems[repo],
)
except SourceReceiptError as exc:
raise SourceReceiptError(f"{repo}: {exc}") from exc
return receipts
def _bundle_input_receipt(*, selected, workspace, push):
"""Freeze only Core files used by the already-applicable WebUI gate.
These are read-only composition inputs, not a new Core-tag/version or
clean-Core prerequisite. Local module candidates intentionally need none.
"""
if not push and "govoplan-core" not in selected:
return {}
if not selected_webui_repository_names(
repo_versions=dict.fromkeys(selected, ""), workspace=workspace
):
return {}
result = {}
core = workspace / "govoplan-core"
for relative in ("webui/package.release.json", "webui/package-lock.release.json"):
path = core / relative
if not path.exists() and not path.is_symlink():
result[relative] = None # The unchanged shared gate explains missing input.
continue
_trusted_ancestry(path.parent)
_owned_path(core, directory=True)
_owned_path(path.parent, directory=True)
observed = _owned_path(path, directory=False)
if not 0 < observed.st_size <= 16 * 1024 * 1024:
raise SourceReceiptError(
"Core release-bundle input exceeds its 16 MiB limit"
)
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(descriptor, "rb") as source:
before = os.fstat(source.fileno())
if (
before.st_dev,
before.st_ino,
before.st_size,
before.st_mtime_ns,
before.st_ctime_ns,
) != (
observed.st_dev,
observed.st_ino,
observed.st_size,
observed.st_mtime_ns,
observed.st_ctime_ns,
):
raise SourceReceiptError(
"Core release-bundle input changed during inspection"
)
content = source.read(before.st_size + 1)
after = os.fstat(source.fileno())
if len(content) != before.st_size or (
before.st_dev,
before.st_ino,
before.st_size,
before.st_mtime_ns,
before.st_ctime_ns,
) != (
after.st_dev,
after.st_ino,
after.st_size,
after.st_mtime_ns,
after.st_ctime_ns,
):
raise SourceReceiptError(
"Core release-bundle input changed during inspection"
)
result[relative] = {
"file": _filesystem_identity(path, observed),
"sha256": hashlib.sha256(content).hexdigest(),
}
return result
def _frozen_receipts(
*, expected, versions, specs, workspace, selected, push, bundle_inputs
):
actual = _collect_receipts(versions=versions, specs=specs, workspace=workspace)
for repo in expected:
if actual[repo] != expected[repo]:
raise SourceReceiptError(
f"{repo}: source receipt changed after whole-batch preflight"
)
for repo in versions:
issues = repository_version_issues(
resolve_repo_path(specs[repo], workspace), expected_version=versions[repo]
)
if issues:
raise SourceReceiptError(
f"{repo}: version/composition changed after whole-batch preflight"
)
if (
_bundle_input_receipt(selected=selected, workspace=workspace, push=push)
!= bundle_inputs
):
raise SourceReceiptError(
"Core release-bundle input receipt changed after whole-batch preflight"
)
if push or "govoplan-core" in selected:
if selected_release_webui_bundle_issues(
repo_versions={repo: versions[repo] for repo in selected},
workspace=workspace,
):
raise SourceReceiptError(
"release WebUI composition changed after whole-batch preflight"
)
def _require_core(receipts, *, push):
core = receipts["govoplan-core"]
if not core["local_tag_object"] or (
push and (not core["remote_tag_object"] or core["remote_main"] != core["head"])
):
raise SourceReceiptError(
"Meta requires the matching annotated Core tag locally and, for publication, remotely"
)
def _blocked(*, selected, apply, push, detail, rows=()): # noqa: A002
known = {row["repo"]: row for row in rows}
identified = next(
(repo for repo in selected if detail.startswith(repo + ":")), None
)
return {
"status": "blocked",
"apply": apply,
"push": push,
"remote": "origin",
"detail": "whole-batch source preflight failed; no selected repository was mutated",
"repositories": [
{
**known.get(repo, {"repo": repo}),
"status": "blocked"
if (
known.get(repo, {}).get("status") == "blocked"
or (not rows and (identified is None or repo == identified))
)
else "skipped",
"detail": known[repo]["detail"]
if known.get(repo, {}).get("status") == "blocked"
else detail,
}
for repo in selected
],
}
def tag_source_batch(
*, repos, repo_versions, workspace_root, remote, message, apply, push
): # noqa: A002
workspace = resolve_workspace_root(workspace_root)
has_meta = "govoplan" in repos
selected = tuple(dict.fromkeys(repos))
if has_meta:
selected = tuple(repo for repo in selected if repo != "govoplan") + (
"govoplan",
)
if not selected:
return {
"status": "noop",
"apply": apply,
"push": push,
"remote": remote.strip() or "origin",
"repositories": [],
}
versions = {repo: normalize_version(repo_versions.get(repo)) for repo in selected}
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
meta_version = versions.get("govoplan")
try:
if remote.strip() not in {"", "origin"}:
raise SourceReceiptError(
"Source-tag batches require the registered origin remote"
)
if any(not version for version in versions.values()):
raise SourceReceiptError(
"every selected repository requires an explicit valid version"
)
if has_meta and versions.get("govoplan-core", meta_version) != meta_version:
raise SourceReceiptError(
"Meta developer-package version must match the selected Core release"
)
# Only Meta requires a frozen version-matched Core source/tag dependency.
if has_meta:
versions.setdefault("govoplan-core", meta_version)
receipts = _collect_receipts(
versions=versions, specs=specs, workspace=workspace
)
if has_meta and "govoplan-core" not in selected:
_require_core(receipts, push=push)
core_issues = (
repository_version_issues(
resolve_repo_path(specs["govoplan-core"], workspace),
expected_version=meta_version,
)
if has_meta
else ()
)
if core_issues:
raise SourceReceiptError(
"Core source metadata must match the selected Meta version"
)
bundle_inputs = _bundle_input_receipt(
selected=selected, workspace=workspace, push=push
)
except (SourceReceiptError, OSError, ValueError) as exc:
return _blocked(selected=selected, apply=apply, push=push, detail=str(exc))
# Preserve the shared complete manifest, package/lock and immutable tag
# preflight. This invocation is always read-only; strict effects stay below.
try:
preview = _preview_repositories(
repos=selected,
repo_versions=repo_versions,
workspace_root=workspace,
remote="origin",
message=message,
push=push,
)
except (OSError, ValueError) as exc:
return _blocked(
selected=selected,
apply=apply,
push=push,
detail=f"shared release preflight could not validate its inputs ({type(exc).__name__})",
)
rows = preview["repositories"]
if preview["status"] in {"blocked", "partial"}:
if not apply:
return preview
return _blocked(
selected=selected,
apply=True,
push=push,
detail="shared release preflight failed",
rows=rows,
)
if not apply:
rows = [
{
**row,
"status": "planned",
"detail": "existing annotated release tag requires atomic main publication",
}
if push
and receipts[row["repo"]]["remote_main"] != receipts[row["repo"]]["head"]
else row
for row in rows
]
preview = {**preview, "repositories": rows}
if any(row["status"] == "planned" for row in rows):
preview["status"] = "planned"
return {
**preview,
"source_receipts": receipts,
"source_contract": "registered-meta-batch-v1"
if has_meta
else "registered-source-batch-v1",
"bundle_input_receipts": bundle_inputs,
}
results = []
effected = False
for row in rows:
repo = row["repo"]
receipt = receipts[repo]
path = resolve_repo_path(specs[repo], workspace)
tag = receipt["tag"]
head = receipt["head"]
try:
# Recheck the entire frozen batch, including Meta and Core, before
# every effect. Earlier successful effects update only their exact
# anticipated tag/branch receipt fields below.
_frozen_receipts(
expected=receipts,
versions=versions,
specs=specs,
workspace=workspace,
selected=selected,
push=push,
bundle_inputs=bundle_inputs,
)
if repo == "govoplan":
_require_core(receipts, push=push)
local_object = receipt["local_tag_object"]
created = False
if not local_object:
if receipt["remote_tag_object"]:
command = (
"git",
"fetch",
"--no-tags",
"origin",
f"refs/tags/{tag}:refs/tags/{tag}",
)
else:
command = ("git", "tag", "-a", tag, head, "-m", row["message"])
created = True
effected = True
if run(command, cwd=path).returncode:
raise SourceReceiptError(
"annotated local release tag could not be created or retrieved"
)
local_object = git_text(
path, "rev-parse", "--verify", f"refs/tags/{tag}"
)
if (
not local_object
or ref_commit(path, f"refs/tags/{tag}") != head
or git_text(path, "cat-file", "-t", f"refs/tags/{tag}") != "tag"
or (
receipt["remote_tag_object"]
and local_object != receipt["remote_tag_object"]
)
):
raise SourceReceiptError("local release tag postcondition failed")
receipt["local_tag_object"] = local_object
_frozen_receipts(
expected=receipts,
versions=versions,
specs=specs,
workspace=workspace,
selected=selected,
push=push,
bundle_inputs=bundle_inputs,
)
if push and (
receipt["remote_tag_object"] != local_object
or receipt["remote_main"] != head
):
# Pin both effects to verified objects, not mutable HEAD/tag
# names. No force, retagging, fallback or non-atomic retry.
command = (
"git",
"push",
"--atomic",
"origin",
f"{head}:refs/heads/main",
f"{local_object}:refs/tags/{tag}",
)
effected = True
if run(command, cwd=path).returncode:
raise SourceReceiptError(
"atomic main and annotated tag publication failed; inspect receipts before retrying"
)
receipt["remote_main"] = head
receipt["remote_tag_object"] = local_object
# Verify remote main AND exact annotated object, as well as local
# source state. A successful Git exit alone is never a receipt.
_frozen_receipts(
expected=receipts,
versions=versions,
specs=specs,
workspace=workspace,
selected=selected,
push=push,
bundle_inputs=bundle_inputs,
)
results.append(
{
**row,
"status": "published" if push else "tagged" if created else "noop",
"detail": "verified strict registered-source release"
if not receipt["remote_tag_object"] or created
else "verified strict registered-source release; immutable annotation already published or present",
"after_local_tag_commit": head,
"after_local_tag_object": local_object,
"after_remote_tag_commit": head
if receipt["remote_tag_object"]
else None,
"after_remote_tag_object": receipt["remote_tag_object"],
"after_remote_main_commit": receipt["remote_main"],
}
)
except (SourceReceiptError, OSError, ValueError) as exc:
results.append(
{
**row,
"status": "failed" if effected else "blocked",
"detail": str(exc),
}
)
results.extend(
{
**later,
"status": "skipped",
"detail": "earlier strict source effect or receipt failed",
}
for later in rows[len(results) :]
)
return {
"status": "partial" if effected else "blocked",
"apply": True,
"push": push,
"remote": "origin",
"repositories": results,
}
status = (
"published"
if push
else "tagged"
if any(row["status"] == "tagged" for row in results)
else "noop"
)
return {
"status": status,
"apply": True,
"push": push,
"remote": "origin",
"repositories": results,
"source_receipts": receipts,
"source_contract": "registered-meta-batch-v1"
if has_meta
else "registered-source-batch-v1",
"bundle_input_receipts": bundle_inputs,
}
@@ -6,12 +6,13 @@ from dataclasses import dataclass
import json
from pathlib import Path
import re
import runpy
import subprocess
import tomllib
from .git_state import collect_versions, sanitized_git_environment
from .git_state import collect_versions, registered_developer_meta_path, sanitized_git_environment
from .registry_reference import registry_artifact_conflicts, registry_entry_source
from .workspace import load_repository_specs, resolve_repo_path
from .workspace import META_ROOT, load_repository_specs, resolve_repo_path
_PYTHON_RELEASE_REF = re.compile(
@@ -43,6 +44,7 @@ def repository_version_issues(
versions = collect_versions(repo_path)
declared = {
"pyproject.toml": versions.pyproject,
"packages/govoplan-meta/pyproject.toml": versions.developer_meta,
"package.json": versions.package,
"webui/package.json": versions.webui_package,
"webui/package.release.json": _json_version(repo_path / "webui" / "package.release.json")
@@ -83,6 +85,8 @@ def repository_version_issues(
for source, version in declared.items()
if version is not None and version != canonical_version
]
if registered_developer_meta_path(repo_path) is not None:
issues.extend(developer_meta_composition_issues(repo_path))
if expected_version is not None and canonical_version.removeprefix("v") != expected_version.removeprefix("v"):
issues.append(
@@ -120,6 +124,34 @@ def repository_version_issues(
return tuple(issues)
def developer_meta_composition_issues(repo_path: Path) -> tuple[VersionAlignmentIssue, ...]:
"""Compare the real nested package with the trusted generator's exact output.
Never execute a generator from a selected checkout. Only the installed
operator tooling provides code; selected TOML/requirements are data inputs.
"""
package_path = registered_developer_meta_path(repo_path)
if package_path is None:
return (VersionAlignmentIssue(repo_path.name, "repository", "registered Meta support repository", "", "nested developer-package identity is not registered"),)
source = "packages/govoplan-meta/pyproject.toml"
try:
current = package_path.read_text(encoding="utf-8")
project = tomllib.loads(current).get("project")
if not isinstance(project, dict) or project.get("name") != "govoplan":
return (VersionAlignmentIssue("govoplan", source + ":project.name", "govoplan", str(project.get("name") if isinstance(project, dict) else ""), "developer meta-package identity must be exact"),)
# META_ROOT belongs to the running operator tools, not repo_path.
generator = runpy.run_path(str(META_ROOT / "tools/release/generate-developer-meta-package.py"))
expected = generator["render"](
workspace=repo_path.parent,
requirements=repo_path / "requirements-release.txt",
)
except (OSError, UnicodeError, KeyError, ValueError, TypeError) as exc:
return (VersionAlignmentIssue("govoplan", source, "readable exact developer composition and Core version", type(exc).__name__, "developer meta-package composition could not be validated"),)
if current != expected:
return (VersionAlignmentIssue("govoplan", source, "trusted generator output matching Core and release requirements", "stale composition", "developer meta-package must exactly match the generator --check contract"),)
return ()
def selected_repository_version_issues(
*,
repo_versions: dict[str, str],
@@ -165,6 +197,11 @@ def selected_repository_version_issues(
return tuple(issues)
def selected_webui_repository_names(*, repo_versions: dict[str, str], workspace: Path) -> tuple[str, ...]:
"""Identify the exact selections for which Core's WebUI inputs are relevant."""
return tuple(repo for repo in sorted(repo_versions) if repo != "govoplan-core" and (workspace / repo / "webui/package.json").exists())
def selected_release_webui_bundle_issues(
*,
repo_versions: dict[str, str],
@@ -177,6 +214,8 @@ def selected_release_webui_bundle_issues(
immutable release package input and lockfile will actually install.
"""
if not selected_webui_repository_names(repo_versions=repo_versions, workspace=workspace):
return ()
core_webui = workspace / "govoplan-core" / "webui"
release_package_path = core_webui / "package.release.json"
release_lock_path = core_webui / "package-lock.release.json"
@@ -34,6 +34,19 @@ def version_metadata_mutations(
) -> tuple[VersionFileMutation, ...]:
"""Render all recognized repository version files without writing them."""
from .git_state import registered_developer_meta_path
if registered_developer_meta_path(repo_path) is not None:
from .meta_preparation import MetaPreparationError, PACKAGE, preview_meta_mutation
try:
_receipt, before, after = preview_meta_mutation(
repo_path=repo_path, target_version=target_version,
)
except (MetaPreparationError, OSError, ValueError, KeyError, TypeError) as exc:
raise VersionMetadataError(str(exc)) from exc
return (VersionFileMutation(PACKAGE, before, after),) if before != after else ()
version = target_version.removeprefix("v")
candidates: list[tuple[Path, str]] = []
if (repo_path / "pyproject.toml").is_file():
@@ -115,6 +128,14 @@ def apply_version_metadata_mutations(
) -> tuple[str, ...]:
"""Apply one deterministic version update, rolling back on write failure."""
from .git_state import registered_developer_meta_path
if registered_developer_meta_path(repo_path) is not None:
raise VersionMetadataError(
"Meta is prepared outside durable runs with prepare-developer-meta-package.py; "
"review its complete generated composition, commit, and create a fresh run."
)
mutations = version_metadata_mutations(
repo_path,
target_version=target_version,
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Preview or explicitly prepare Meta outside a durable release run."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from govoplan_release.meta_preparation import (
MetaPreparationAmbiguous,
MetaPreparationError,
_read_input,
prepare_developer_meta_package,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--workspace", type=Path, required=True)
parser.add_argument("--target-version", required=True)
parser.add_argument("--apply", action="store_true")
parser.add_argument("--receipt", type=Path)
parser.add_argument("--confirm-out-of-run", action="store_true")
args = parser.parse_args()
try:
expected = None
if args.apply:
if args.receipt is None:
raise MetaPreparationError(
"Apply requires the reviewed preview JSON via --receipt."
)
payload, _ = _read_input(args.receipt)
expected = json.loads(payload)["receipt"]
result = prepare_developer_meta_package(
repo_path=args.workspace.absolute() / "govoplan",
target_version=args.target_version,
apply=args.apply,
expected_receipt=expected,
confirm_out_of_run=args.confirm_out_of_run,
)
except (MetaPreparationError, OSError, ValueError, KeyError, TypeError) as exc:
status = "needs-reconciliation" if isinstance(exc, MetaPreparationAmbiguous) else "blocked"
print(json.dumps({"status": status, "detail": str(exc)}))
return 1
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -2141,6 +2141,7 @@
function primaryVersion(repo) {
const versions = repo.versions || {};
if (versions.pyproject) return versions.pyproject;
if (versions.developer_meta) return versions.developer_meta;
if (versions.package) return versions.package;
if (versions.webui_package) return versions.webui_package;
if (Array.isArray(versions.manifests) && versions.manifests.length) return versions.manifests[0];