Complete guided release console workflow
This commit is contained in:
@@ -222,6 +222,7 @@ class ReleasePlanUnit:
|
||||
status: str
|
||||
blockers: tuple[str, ...] = ()
|
||||
warnings: tuple[str, ...] = ()
|
||||
capabilities: tuple[str, ...] = ()
|
||||
provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = ()
|
||||
requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = ()
|
||||
gate_findings: tuple[ReleaseGateFinding, ...] = ()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2218,7 +2218,7 @@ def _validated_result_receipt(
|
||||
"Catalog publication receipt identity is invalid."
|
||||
)
|
||||
return dict(value)
|
||||
if set(value) != {
|
||||
repository_fields = {
|
||||
"kind",
|
||||
"repo",
|
||||
"head",
|
||||
@@ -2228,7 +2228,8 @@ def _validated_result_receipt(
|
||||
"worktree_clean",
|
||||
"target_tag",
|
||||
"tag_object",
|
||||
}:
|
||||
}
|
||||
if set(value) not in (repository_fields, repository_fields | {"worktree_sha256"}):
|
||||
raise ReleaseRunConflict("Repository result receipt is invalid.")
|
||||
if (
|
||||
not isinstance(value.get("repo"), str)
|
||||
@@ -2241,6 +2242,10 @@ def _validated_result_receipt(
|
||||
or value.get("remote") != "origin"
|
||||
or not _is_sha256(value.get("remote_sha256"))
|
||||
or type(value.get("worktree_clean")) is not bool
|
||||
or (
|
||||
"worktree_sha256" in value
|
||||
and not _is_sha256(value.get("worktree_sha256"))
|
||||
)
|
||||
or not isinstance(value.get("target_tag"), str)
|
||||
or len(value["target_tag"]) > 160
|
||||
or (
|
||||
|
||||
@@ -25,6 +25,10 @@ from .version_alignment import (
|
||||
selected_release_webui_bundle_issues,
|
||||
selected_repository_version_issues,
|
||||
)
|
||||
from .version_metadata import (
|
||||
VersionMetadataError,
|
||||
version_metadata_mutations,
|
||||
)
|
||||
|
||||
|
||||
def build_selective_release_plan(
|
||||
@@ -46,6 +50,7 @@ def build_selective_release_plan(
|
||||
)
|
||||
for repo in repositories
|
||||
)
|
||||
units = dependency_ordered_units(units)
|
||||
units = apply_repository_version_gate(
|
||||
units, workspace=Path(dashboard.workspace_root)
|
||||
)
|
||||
@@ -103,7 +108,35 @@ def apply_repository_version_gate(
|
||||
workspace: Path,
|
||||
) -> tuple[ReleasePlanUnit, ...]:
|
||||
issues_by_repo: dict[str, list[ReleaseGateFinding]] = {}
|
||||
version_update_supported_by_repo: dict[str, bool] = {}
|
||||
deferred_core_lock_repos: set[str] = set()
|
||||
for unit in units:
|
||||
version_update_supported = unit.current_version == unit.target_version
|
||||
if unit.current_version and unit.current_version != unit.target_version:
|
||||
try:
|
||||
version_update_supported = bool(
|
||||
version_metadata_mutations(
|
||||
workspace / unit.repo,
|
||||
target_version=unit.target_version,
|
||||
)
|
||||
)
|
||||
except VersionMetadataError as exc:
|
||||
issues_by_repo.setdefault(unit.repo, []).append(
|
||||
ReleaseGateFinding(
|
||||
code="repository_version_mutation_unsupported",
|
||||
severity="blocker",
|
||||
message=str(exc),
|
||||
remediation=(
|
||||
"Align the unsupported version declarations manually, "
|
||||
"commit the reviewed result, then rebuild this plan."
|
||||
),
|
||||
repo=unit.repo,
|
||||
source="repository version metadata",
|
||||
expected=unit.target_version,
|
||||
actual=unit.current_version,
|
||||
)
|
||||
)
|
||||
version_update_supported_by_repo[unit.repo] = version_update_supported
|
||||
try:
|
||||
issues = selected_repository_version_issues(
|
||||
repo_versions={unit.repo: unit.target_version},
|
||||
@@ -130,6 +163,18 @@ def apply_repository_version_gate(
|
||||
)
|
||||
continue
|
||||
for issue in issues:
|
||||
if (
|
||||
version_update_supported
|
||||
and issue.message
|
||||
== "source version must match the requested release version"
|
||||
):
|
||||
continue
|
||||
if (
|
||||
unit.repo == "govoplan-core"
|
||||
and issue.source.startswith("package-lock.release.json")
|
||||
):
|
||||
deferred_core_lock_repos.add(unit.repo)
|
||||
continue
|
||||
issues_by_repo.setdefault(issue.repo, []).append(
|
||||
ReleaseGateFinding(
|
||||
code="repository_version_alignment",
|
||||
@@ -161,17 +206,68 @@ def apply_repository_version_gate(
|
||||
source_preflight_ready=False,
|
||||
)
|
||||
if unit.repo in issues_by_repo
|
||||
else unit
|
||||
else replace(
|
||||
unit,
|
||||
warnings=planned_version_warnings(
|
||||
unit,
|
||||
defer_core_lock=unit.repo in deferred_core_lock_repos,
|
||||
),
|
||||
status=(
|
||||
"attention"
|
||||
if (
|
||||
(
|
||||
unit.current_version
|
||||
and unit.current_version != unit.target_version
|
||||
)
|
||||
or unit.repo in deferred_core_lock_repos
|
||||
)
|
||||
and unit.status == "ready"
|
||||
else unit.status
|
||||
),
|
||||
source_preflight_ready=(
|
||||
unit.source_preflight_ready
|
||||
or (
|
||||
bool(unit.current_version)
|
||||
and unit.current_version != unit.target_version
|
||||
and version_update_supported_by_repo.get(unit.repo, False)
|
||||
and not any(
|
||||
finding.code == "worktree_dirty"
|
||||
for finding in unit.gate_findings
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
for unit in units
|
||||
)
|
||||
|
||||
|
||||
def planned_version_warnings(
|
||||
unit: ReleasePlanUnit,
|
||||
*,
|
||||
defer_core_lock: bool,
|
||||
) -> tuple[str, ...]:
|
||||
warnings = list(unit.warnings)
|
||||
if unit.current_version and unit.current_version != unit.target_version:
|
||||
warnings.append(
|
||||
"version metadata will be updated by the bounded release executor"
|
||||
)
|
||||
if defer_core_lock:
|
||||
warnings.append(
|
||||
"Core release lock metadata will be regenerated by the bounded "
|
||||
"bundle executor"
|
||||
)
|
||||
return tuple(warnings)
|
||||
|
||||
|
||||
def apply_release_webui_bundle_gate(
|
||||
units: tuple[ReleasePlanUnit, ...],
|
||||
*,
|
||||
workspace: Path,
|
||||
) -> tuple[ReleasePlanUnit, ...]:
|
||||
issues_by_repo: dict[str, list[ReleaseGateFinding]] = {}
|
||||
selected = {unit.repo for unit in units}
|
||||
core_selected = "govoplan-core" in selected
|
||||
planned_bundle_repos: set[str] = set()
|
||||
try:
|
||||
issues = selected_release_webui_bundle_issues(
|
||||
repo_versions={unit.repo: unit.target_version for unit in units},
|
||||
@@ -200,6 +296,9 @@ def apply_release_webui_bundle_gate(
|
||||
)
|
||||
issues = ()
|
||||
for issue in issues:
|
||||
if core_selected and issue.repo in selected and issue.repo != "govoplan-core":
|
||||
planned_bundle_repos.add(issue.repo)
|
||||
continue
|
||||
issues_by_repo.setdefault(issue.repo, []).append(
|
||||
ReleaseGateFinding(
|
||||
code="release_webui_composition_alignment",
|
||||
@@ -230,7 +329,25 @@ def apply_release_webui_bundle_gate(
|
||||
source_preflight_ready=False,
|
||||
)
|
||||
if unit.repo in issues_by_repo
|
||||
else unit
|
||||
else replace(
|
||||
unit,
|
||||
warnings=unit.warnings
|
||||
+ (
|
||||
(
|
||||
"Core release WebUI references will be regenerated after "
|
||||
"the selected module tags are created"
|
||||
),
|
||||
)
|
||||
if unit.repo == "govoplan-core" and planned_bundle_repos
|
||||
else unit.warnings,
|
||||
status=(
|
||||
"attention"
|
||||
if unit.repo == "govoplan-core"
|
||||
and planned_bundle_repos
|
||||
and unit.status == "ready"
|
||||
else unit.status
|
||||
),
|
||||
)
|
||||
for unit in units
|
||||
)
|
||||
|
||||
@@ -408,6 +525,7 @@ def build_unit(
|
||||
status="blocked" if blockers else "attention" if warnings else "ready",
|
||||
blockers=tuple(blockers),
|
||||
warnings=tuple(warnings),
|
||||
capabilities=repository_capabilities(repo, contracts=contracts),
|
||||
provides_interfaces=provides,
|
||||
requires_interfaces=requires,
|
||||
gate_findings=tuple(findings),
|
||||
@@ -416,11 +534,83 @@ def build_unit(
|
||||
and not repo.dirty
|
||||
and repo.has_head
|
||||
and bool(repo.branch)
|
||||
and current_version == resolved_target
|
||||
and current_version is not None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def repository_capabilities(
|
||||
repo: RepositorySnapshot,
|
||||
*,
|
||||
contracts: ModuleContractSnapshot | None,
|
||||
) -> tuple[str, ...]:
|
||||
"""Describe the release operations applicable to one repository shape."""
|
||||
|
||||
root = Path(repo.absolute_path)
|
||||
capabilities = {"git-source"}
|
||||
if (root / "pyproject.toml").is_file():
|
||||
capabilities.add("python-package")
|
||||
if (root / "package.json").is_file() or (root / "webui/package.json").is_file():
|
||||
capabilities.add("webui-package")
|
||||
if contracts is not None:
|
||||
capabilities.add("module-manifest")
|
||||
if (
|
||||
(root / "alembic/versions").is_dir()
|
||||
or (root / "src").is_dir()
|
||||
and any((root / "src").glob("**/migrations/versions"))
|
||||
):
|
||||
capabilities.add("database-migrations")
|
||||
if (root / "docs").is_dir() or repo.spec.subtype in {"docs", "documentation"}:
|
||||
capabilities.add("documentation")
|
||||
if repo.spec.name == "govoplan-core":
|
||||
capabilities.add("core-release-bundle")
|
||||
if repo.spec.name == "govoplan":
|
||||
capabilities.add("release-control-plane")
|
||||
return tuple(sorted(capabilities))
|
||||
|
||||
|
||||
def dependency_ordered_units(
|
||||
units: tuple[ReleasePlanUnit, ...],
|
||||
) -> tuple[ReleasePlanUnit, ...]:
|
||||
"""Order module providers before consumers while keeping Core last."""
|
||||
|
||||
by_repo = {unit.repo: unit for unit in units}
|
||||
providers: dict[str, set[str]] = {}
|
||||
for unit in units:
|
||||
if unit.repo == "govoplan-core":
|
||||
continue
|
||||
for provided in unit.provides_interfaces:
|
||||
providers.setdefault(provided.name, set()).add(unit.repo)
|
||||
|
||||
dependencies: dict[str, set[str]] = {unit.repo: set() for unit in units}
|
||||
for unit in units:
|
||||
for requirement in unit.requires_interfaces:
|
||||
dependencies[unit.repo].update(
|
||||
provider
|
||||
for provider in providers.get(requirement.name, set())
|
||||
if provider != unit.repo
|
||||
)
|
||||
if "govoplan-core" in dependencies:
|
||||
dependencies["govoplan-core"].update(
|
||||
repo for repo in by_repo if repo != "govoplan-core"
|
||||
)
|
||||
|
||||
ordered: list[ReleasePlanUnit] = []
|
||||
remaining = set(by_repo)
|
||||
while remaining:
|
||||
ready = sorted(
|
||||
repo
|
||||
for repo in remaining
|
||||
if not (dependencies[repo] & remaining)
|
||||
)
|
||||
if not ready:
|
||||
ready = [sorted(remaining)[0]]
|
||||
for repo in ready:
|
||||
ordered.append(by_repo[repo])
|
||||
remaining.remove(repo)
|
||||
return tuple(ordered)
|
||||
|
||||
|
||||
def gate_finding(
|
||||
*,
|
||||
repo: str,
|
||||
@@ -501,24 +691,38 @@ def dry_run_steps(
|
||||
) -> tuple[ReleasePlanStep, ...]:
|
||||
steps: list[ReleasePlanStep] = []
|
||||
snapshots = {repo.spec.name: repo for repo in dashboard.repositories}
|
||||
selected_names = {unit.repo for unit in units}
|
||||
if "govoplan-core" in selected_names and len(selected_names) > 1:
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
id="release:cross-repository-ordering",
|
||||
title="Resolve module/Core release ordering",
|
||||
detail=(
|
||||
"A combined Core/module release needs a dependency-aware DAG: "
|
||||
"local module tags, Core release-lock regeneration and commit, "
|
||||
"Core tag, full alignment, then atomic pushes. The current linear "
|
||||
"executor must not publish a module before that lock is committed."
|
||||
),
|
||||
command=None,
|
||||
cwd=dashboard.meta_root,
|
||||
mutating=True,
|
||||
status="needs-executor",
|
||||
core_unit = next((unit for unit in units if unit.repo == "govoplan-core"), None)
|
||||
non_core_units = tuple(unit for unit in units if unit.repo != "govoplan-core")
|
||||
bundle_repos: tuple[str, ...] = ()
|
||||
core_lock_regeneration = False
|
||||
if core_unit is not None:
|
||||
try:
|
||||
bundle_repos = tuple(
|
||||
sorted(
|
||||
{
|
||||
issue.repo
|
||||
for issue in selected_release_webui_bundle_issues(
|
||||
repo_versions={
|
||||
unit.repo: unit.target_version for unit in units
|
||||
},
|
||||
workspace=Path(dashboard.workspace_root),
|
||||
)
|
||||
if issue.repo != "govoplan-core"
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
core_lock_regeneration = bool(bundle_repos) or any(
|
||||
issue.source.startswith("package-lock.release.json")
|
||||
for issue in selected_repository_version_issues(
|
||||
repo_versions={
|
||||
"govoplan-core": core_unit.target_version,
|
||||
},
|
||||
workspace=Path(dashboard.workspace_root),
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a structured gate already reports it.
|
||||
bundle_repos = ()
|
||||
|
||||
for unit in units:
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
@@ -531,32 +735,74 @@ def dry_run_steps(
|
||||
repo=unit.repo,
|
||||
)
|
||||
)
|
||||
|
||||
for unit in units:
|
||||
if unit.current_version != unit.target_version:
|
||||
try:
|
||||
paths = tuple(
|
||||
mutation.path
|
||||
for mutation in version_metadata_mutations(
|
||||
Path(dashboard.workspace_root) / unit.repo,
|
||||
target_version=unit.target_version,
|
||||
)
|
||||
)
|
||||
except VersionMetadataError:
|
||||
paths = ()
|
||||
snapshot = snapshots.get(unit.repo)
|
||||
version_supported = bool(
|
||||
paths and snapshot is not None and not snapshot.dirty
|
||||
)
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
id=f"{unit.repo}:version",
|
||||
title=f"Update {unit.repo} version metadata",
|
||||
detail=f"Prepare version files and manifest metadata for {unit.target_version}.",
|
||||
command=None,
|
||||
detail=(
|
||||
f"Update only the recognized version declarations for "
|
||||
f"{unit.target_version}: {', '.join(paths) or 'unsupported metadata'}."
|
||||
),
|
||||
command=(
|
||||
"bounded version metadata update "
|
||||
+ " ".join(shlex.quote(path) for path in paths)
|
||||
)
|
||||
if paths
|
||||
else None,
|
||||
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
||||
mutating=True,
|
||||
repo=unit.repo,
|
||||
status="needs-executor",
|
||||
status="planned" if version_supported else "needs-executor",
|
||||
)
|
||||
)
|
||||
|
||||
for unit in non_core_units:
|
||||
snapshot = snapshots.get(unit.repo)
|
||||
if unit.current_version != unit.target_version or (
|
||||
needs_commit = unit.current_version != unit.target_version or (
|
||||
snapshot is not None and snapshot.dirty
|
||||
):
|
||||
)
|
||||
if needs_commit:
|
||||
commit_supported = bool(
|
||||
unit.current_version != unit.target_version
|
||||
and snapshot is not None
|
||||
and not snapshot.dirty
|
||||
)
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
id=f"{unit.repo}:commit",
|
||||
title=f"Commit {unit.repo} release changes",
|
||||
detail="Commit reviewed changes for this release unit only.",
|
||||
command=f"git add -A && git commit -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}",
|
||||
detail=(
|
||||
"Commit only the receipt-bound files written by the version "
|
||||
"executor."
|
||||
if commit_supported
|
||||
else "Pre-existing worktree changes require review outside the release executor."
|
||||
),
|
||||
command=(
|
||||
f"git commit -m {shlex.quote(f'Release {unit.repo} {unit.target_tag}')}"
|
||||
if commit_supported
|
||||
else None
|
||||
),
|
||||
cwd=f"{dashboard.workspace_root}/{unit.repo}",
|
||||
mutating=True,
|
||||
repo=unit.repo,
|
||||
status="planned" if commit_supported else "needs-executor",
|
||||
)
|
||||
)
|
||||
steps.append(
|
||||
@@ -570,6 +816,97 @@ def dry_run_steps(
|
||||
repo=unit.repo,
|
||||
)
|
||||
)
|
||||
|
||||
if core_unit is not None:
|
||||
if core_lock_regeneration:
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
id="govoplan-core:bundle",
|
||||
title="Regenerate the Core release WebUI bundle",
|
||||
detail=(
|
||||
"After local module tags exist, update and lock the exact "
|
||||
"selected WebUI references"
|
||||
+ (
|
||||
" for: " + ", ".join(bundle_repos)
|
||||
if bundle_repos
|
||||
else ""
|
||||
)
|
||||
+ ", and reconcile package/lock metadata."
|
||||
),
|
||||
command=(
|
||||
"tools/release/generate-release-lock.sh "
|
||||
+ " ".join(
|
||||
f"--local-git-repo {shlex.quote(str(Path(dashboard.workspace_root) / repo))}"
|
||||
for repo in bundle_repos
|
||||
)
|
||||
),
|
||||
cwd=dashboard.meta_root,
|
||||
mutating=True,
|
||||
repo="govoplan-core",
|
||||
)
|
||||
)
|
||||
core_snapshot = snapshots.get(core_unit.repo)
|
||||
core_needs_commit = (
|
||||
core_unit.current_version != core_unit.target_version
|
||||
or core_lock_regeneration
|
||||
or (core_snapshot is not None and core_snapshot.dirty)
|
||||
)
|
||||
if core_needs_commit:
|
||||
core_commit_supported = bool(
|
||||
core_snapshot is not None
|
||||
and not core_snapshot.dirty
|
||||
and (
|
||||
core_unit.current_version != core_unit.target_version
|
||||
or core_lock_regeneration
|
||||
)
|
||||
)
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
id="govoplan-core:commit",
|
||||
title="Commit govoplan-core release changes",
|
||||
detail=(
|
||||
"Commit only the receipt-bound Core version and release-bundle files."
|
||||
if core_commit_supported
|
||||
else "Pre-existing Core worktree changes require review outside the release executor."
|
||||
),
|
||||
command=(
|
||||
f"git commit -m {shlex.quote(f'Release govoplan-core {core_unit.target_tag}')}"
|
||||
if core_commit_supported
|
||||
else None
|
||||
),
|
||||
cwd=f"{dashboard.workspace_root}/govoplan-core",
|
||||
mutating=True,
|
||||
repo="govoplan-core",
|
||||
status="planned" if core_commit_supported else "needs-executor",
|
||||
)
|
||||
)
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
id="govoplan-core:tag",
|
||||
title=f"Create govoplan-core tag {core_unit.target_tag}",
|
||||
detail="Create the Core tag only after selected module tags and the release lock are verified.",
|
||||
command=f"git tag -a {shlex.quote(core_unit.target_tag)} -m {shlex.quote(f'Release govoplan-core {core_unit.target_tag}')}",
|
||||
cwd=f"{dashboard.workspace_root}/govoplan-core",
|
||||
mutating=True,
|
||||
repo="govoplan-core",
|
||||
)
|
||||
)
|
||||
|
||||
if units:
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
id="release:alignment",
|
||||
title="Verify frozen source and release composition",
|
||||
detail=(
|
||||
"Re-run version, contract, tag, and Core WebUI composition gates "
|
||||
"after local mutations and before any remote publication."
|
||||
),
|
||||
command="receipt-bound release alignment",
|
||||
cwd=dashboard.meta_root,
|
||||
)
|
||||
)
|
||||
|
||||
for unit in units:
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
id=f"{unit.repo}:push",
|
||||
@@ -640,6 +977,20 @@ def dry_run_steps(
|
||||
status="planned",
|
||||
)
|
||||
)
|
||||
steps.append(
|
||||
ReleasePlanStep(
|
||||
id="release:install-verify",
|
||||
title="Verify candidate package installation",
|
||||
detail=(
|
||||
"Install every selected candidate wheel into a private "
|
||||
"temporary target without network access or dependencies, "
|
||||
"then verify installed package metadata against the frozen plan."
|
||||
),
|
||||
command="python -m pip install --no-index --no-deps <candidate wheels>",
|
||||
cwd=dashboard.meta_root,
|
||||
status="planned",
|
||||
)
|
||||
)
|
||||
return tuple(steps)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Deterministic, bounded updates for repository-owned version metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
import tomllib
|
||||
|
||||
|
||||
MAX_VERSION_FILES = 64
|
||||
MAX_VERSION_FILE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
class VersionMetadataError(RuntimeError):
|
||||
"""Raised when release version metadata cannot be changed safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VersionFileMutation:
|
||||
path: str
|
||||
before: bytes
|
||||
after: bytes
|
||||
|
||||
|
||||
def version_metadata_mutations(
|
||||
repo_path: Path,
|
||||
*,
|
||||
target_version: str,
|
||||
) -> tuple[VersionFileMutation, ...]:
|
||||
"""Render all recognized repository version files without writing them."""
|
||||
|
||||
version = target_version.removeprefix("v")
|
||||
candidates: list[tuple[Path, str]] = []
|
||||
if (repo_path / "pyproject.toml").is_file():
|
||||
candidates.append((repo_path / "pyproject.toml", "pyproject"))
|
||||
for package_name, lock_name in (
|
||||
("package.json", "package-lock.json"),
|
||||
("webui/package.json", "webui/package-lock.json"),
|
||||
("webui/package.release.json", "webui/package-lock.release.json"),
|
||||
):
|
||||
package = repo_path / package_name
|
||||
if not package.is_file():
|
||||
continue
|
||||
candidates.append((package, "package"))
|
||||
lock = repo_path / lock_name
|
||||
if lock.is_file():
|
||||
candidates.append((lock, "lock"))
|
||||
src = repo_path / "src"
|
||||
if src.is_dir():
|
||||
candidates.extend(
|
||||
(path, "manifest")
|
||||
for path in sorted(src.glob("**/backend/manifest.py"))
|
||||
if path.is_file()
|
||||
)
|
||||
candidates.extend(
|
||||
(path, "package_init")
|
||||
for path in sorted(src.glob("*/__init__.py"))
|
||||
if path.is_file()
|
||||
)
|
||||
if len(candidates) > MAX_VERSION_FILES:
|
||||
raise VersionMetadataError("Repository version metadata exceeds its file bound.")
|
||||
|
||||
mutations: list[VersionFileMutation] = []
|
||||
declarations = 0
|
||||
for path, kind in candidates:
|
||||
before = _read_bounded(path)
|
||||
if kind == "pyproject":
|
||||
after, found = _render_pyproject(before, version=version)
|
||||
elif kind == "package":
|
||||
after, found = _render_package_json(before, version=version)
|
||||
elif kind == "lock":
|
||||
after, found = _render_package_lock(before, version=version)
|
||||
elif kind == "manifest":
|
||||
after, found = _render_python_version(
|
||||
before,
|
||||
path=path,
|
||||
target_name="ModuleManifest",
|
||||
keyword_name="version",
|
||||
version=version,
|
||||
)
|
||||
else:
|
||||
after, found = _render_python_assignment(
|
||||
before,
|
||||
path=path,
|
||||
assignment_name="__version__",
|
||||
version=version,
|
||||
)
|
||||
if not found:
|
||||
continue
|
||||
declarations += 1
|
||||
if before != after:
|
||||
mutations.append(
|
||||
VersionFileMutation(
|
||||
path=path.relative_to(repo_path).as_posix(),
|
||||
before=before,
|
||||
after=after,
|
||||
)
|
||||
)
|
||||
if declarations == 0:
|
||||
raise VersionMetadataError(
|
||||
"Repository has no supported version declaration to update."
|
||||
)
|
||||
return tuple(mutations)
|
||||
|
||||
|
||||
def apply_version_metadata_mutations(
|
||||
repo_path: Path,
|
||||
*,
|
||||
target_version: str,
|
||||
) -> tuple[str, ...]:
|
||||
"""Apply one deterministic version update, rolling back on write failure."""
|
||||
|
||||
mutations = version_metadata_mutations(
|
||||
repo_path,
|
||||
target_version=target_version,
|
||||
)
|
||||
written: list[VersionFileMutation] = []
|
||||
try:
|
||||
for mutation in mutations:
|
||||
destination = repo_path / mutation.path
|
||||
if _read_bounded(destination) != mutation.before:
|
||||
raise VersionMetadataError(
|
||||
f"Version metadata changed before update: {mutation.path}"
|
||||
)
|
||||
_atomic_write(destination, mutation.after)
|
||||
written.append(mutation)
|
||||
except Exception:
|
||||
for mutation in reversed(written):
|
||||
_atomic_write(repo_path / mutation.path, mutation.before)
|
||||
raise
|
||||
return tuple(mutation.path for mutation in mutations)
|
||||
|
||||
|
||||
def version_metadata_paths(
|
||||
repo_path: Path,
|
||||
*,
|
||||
target_version: str,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return the exact paths a deterministic target-version update owns."""
|
||||
|
||||
return tuple(
|
||||
mutation.path
|
||||
for mutation in version_metadata_mutations(
|
||||
repo_path,
|
||||
target_version=target_version,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _read_bounded(path: Path) -> bytes:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise VersionMetadataError(
|
||||
f"Version metadata is unavailable: {path.name}"
|
||||
) from exc
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise VersionMetadataError(
|
||||
f"Version metadata must be a regular file: {path.name}"
|
||||
)
|
||||
if metadata.st_size > MAX_VERSION_FILE_BYTES:
|
||||
raise VersionMetadataError(
|
||||
f"Version metadata exceeds its size bound: {path.name}"
|
||||
)
|
||||
try:
|
||||
return path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise VersionMetadataError(
|
||||
f"Version metadata could not be read: {path.name}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _decode(payload: bytes, *, path: Path | None = None) -> str:
|
||||
try:
|
||||
return payload.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
label = path.name if path is not None else "file"
|
||||
raise VersionMetadataError(
|
||||
f"Version metadata is not UTF-8: {label}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _render_pyproject(payload: bytes, *, version: str) -> tuple[bytes, bool]:
|
||||
text = _decode(payload)
|
||||
try:
|
||||
parsed = tomllib.loads(text)
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
raise VersionMetadataError("pyproject.toml is malformed.") from exc
|
||||
project = parsed.get("project")
|
||||
if not isinstance(project, dict) or not isinstance(project.get("version"), str):
|
||||
return payload, False
|
||||
section = re.search(
|
||||
r"(?ms)^\[project\][^\n]*\n(?P<body>.*?)(?=^\[[^\n]+\]|\Z)",
|
||||
text,
|
||||
)
|
||||
if section is None:
|
||||
raise VersionMetadataError("pyproject.toml has no editable [project] block.")
|
||||
body = section.group("body")
|
||||
match = re.search(
|
||||
r'(?m)^(?P<prefix>\s*version\s*=\s*)(?P<quote>["\'])(?P<value>[^"\']+)(?P=quote)(?P<suffix>\s*(?:#.*)?)$',
|
||||
body,
|
||||
)
|
||||
if match is None:
|
||||
raise VersionMetadataError(
|
||||
"pyproject.toml project.version is not a supported literal."
|
||||
)
|
||||
start = section.start("body") + match.start("value")
|
||||
end = section.start("body") + match.end("value")
|
||||
rendered = f"{text[:start]}{version}{text[end:]}"
|
||||
tomllib.loads(rendered)
|
||||
return rendered.encode("utf-8"), True
|
||||
|
||||
|
||||
def _render_package_json(payload: bytes, *, version: str) -> tuple[bytes, bool]:
|
||||
data = _json_object(payload, label="package metadata")
|
||||
if not isinstance(data.get("version"), str):
|
||||
return payload, False
|
||||
data["version"] = version
|
||||
return _json_bytes(data), True
|
||||
|
||||
|
||||
def _render_package_lock(payload: bytes, *, version: str) -> tuple[bytes, bool]:
|
||||
data = _json_object(payload, label="package lock")
|
||||
found = False
|
||||
if isinstance(data.get("version"), str):
|
||||
data["version"] = version
|
||||
found = True
|
||||
packages = data.get("packages")
|
||||
root = packages.get("") if isinstance(packages, dict) else None
|
||||
if isinstance(root, dict) and isinstance(root.get("version"), str):
|
||||
root["version"] = version
|
||||
found = True
|
||||
return (_json_bytes(data), True) if found else (payload, False)
|
||||
|
||||
|
||||
def _json_object(payload: bytes, *, label: str) -> dict[str, object]:
|
||||
try:
|
||||
value = json.loads(_decode(payload))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise VersionMetadataError(f"{label.capitalize()} is malformed.") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise VersionMetadataError(f"{label.capitalize()} must be an object.")
|
||||
return value
|
||||
|
||||
|
||||
def _json_bytes(value: dict[str, object]) -> bytes:
|
||||
return (
|
||||
json.dumps(value, indent=2, ensure_ascii=True, separators=(",", ": "))
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _render_python_version(
|
||||
payload: bytes,
|
||||
*,
|
||||
path: Path,
|
||||
target_name: str,
|
||||
keyword_name: str,
|
||||
version: str,
|
||||
) -> tuple[bytes, bool]:
|
||||
text = _decode(payload, path=path)
|
||||
try:
|
||||
tree = ast.parse(text, filename=str(path))
|
||||
except SyntaxError as exc:
|
||||
raise VersionMetadataError(f"Python metadata is malformed: {path.name}") from exc
|
||||
values: list[ast.Constant] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call) or _call_name(node.func) != target_name:
|
||||
continue
|
||||
for keyword in node.keywords:
|
||||
if (
|
||||
keyword.arg == keyword_name
|
||||
and isinstance(keyword.value, ast.Constant)
|
||||
and isinstance(keyword.value.value, str)
|
||||
):
|
||||
values.append(keyword.value)
|
||||
if not values:
|
||||
return payload, False
|
||||
if len(values) != 1:
|
||||
raise VersionMetadataError(
|
||||
f"Python metadata has multiple {target_name}.{keyword_name} values: {path.name}"
|
||||
)
|
||||
return _replace_python_literal(text, values[0], version=version, path=path), True
|
||||
|
||||
|
||||
def _render_python_assignment(
|
||||
payload: bytes,
|
||||
*,
|
||||
path: Path,
|
||||
assignment_name: str,
|
||||
version: str,
|
||||
) -> tuple[bytes, bool]:
|
||||
text = _decode(payload, path=path)
|
||||
try:
|
||||
tree = ast.parse(text, filename=str(path))
|
||||
except SyntaxError as exc:
|
||||
raise VersionMetadataError(f"Python metadata is malformed: {path.name}") from exc
|
||||
values: list[ast.Constant] = []
|
||||
for node in tree.body:
|
||||
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||||
continue
|
||||
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
||||
value = node.value
|
||||
if (
|
||||
any(
|
||||
isinstance(target, ast.Name) and target.id == assignment_name
|
||||
for target in targets
|
||||
)
|
||||
and isinstance(value, ast.Constant)
|
||||
and isinstance(value.value, str)
|
||||
):
|
||||
values.append(value)
|
||||
if not values:
|
||||
return payload, False
|
||||
if len(values) != 1:
|
||||
raise VersionMetadataError(
|
||||
f"Python metadata has multiple {assignment_name} assignments: {path.name}"
|
||||
)
|
||||
return _replace_python_literal(text, values[0], version=version, path=path), True
|
||||
|
||||
|
||||
def _replace_python_literal(
|
||||
text: str,
|
||||
node: ast.Constant,
|
||||
*,
|
||||
version: str,
|
||||
path: Path,
|
||||
) -> bytes:
|
||||
if (
|
||||
node.lineno != node.end_lineno
|
||||
or node.end_col_offset is None
|
||||
or node.col_offset < 0
|
||||
):
|
||||
raise VersionMetadataError(
|
||||
f"Python version declaration must be a single-line literal: {path.name}"
|
||||
)
|
||||
lines = text.splitlines(keepends=True)
|
||||
line = lines[node.lineno - 1]
|
||||
lines[node.lineno - 1] = (
|
||||
line[: node.col_offset]
|
||||
+ json.dumps(version)
|
||||
+ line[node.end_col_offset :]
|
||||
)
|
||||
rendered = "".join(lines)
|
||||
ast.parse(rendered, filename=str(path))
|
||||
return rendered.encode("utf-8")
|
||||
|
||||
|
||||
def _call_name(node: ast.expr) -> str | None:
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
if isinstance(node, ast.Attribute):
|
||||
return node.attr
|
||||
return None
|
||||
|
||||
|
||||
def _atomic_write(path: Path, payload: bytes) -> None:
|
||||
metadata = path.stat()
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
dir=path.parent,
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
os.fchmod(descriptor, metadata.st_mode & 0o777)
|
||||
with os.fdopen(descriptor, "wb") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary.replace(path)
|
||||
directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
except Exception:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
Reference in New Issue
Block a user