Harden source release preparation and record verified security follow-up
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user