655 lines
28 KiB
Python
655 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""Build a deterministic release manifest from one clean, explicit Git commit."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tomllib
|
|
from dataclasses import dataclass
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any, Iterable
|
|
|
|
|
|
SCHEMA_VERSION = "yuqei.release-source-manifest.v1"
|
|
FULL_COMMIT_RE = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})")
|
|
BASELINE_PATH = "docs/qa/multiscene-closeout-20260910/delivery-baseline-v1.json"
|
|
PROMPT_DEFINITION_PATHS = (
|
|
"yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/drafting_prompt_modules.json",
|
|
"yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/review_scene_modules.json",
|
|
)
|
|
PYPROJECT_PATHS = (
|
|
"yuqei-contract-platform/services/contract-api/pyproject.toml",
|
|
"yuqei-ai-platform/services/ai-platform-api/pyproject.toml",
|
|
"yuqei-ai-platform/services/worker/pyproject.toml",
|
|
"yuqei-ai-sdk-python/pyproject.toml",
|
|
)
|
|
OPENAPI_PATHS = (
|
|
"yuqei-contract-platform/packages/contract-openapi/openapi.yaml",
|
|
"yuqei-ai-platform/packages/ai-openapi/openapi.yaml",
|
|
)
|
|
REQUIREMENT_LOCK_PATHS = (
|
|
"yuqei-infra/requirements/contract-api-linux-x86_64-py312.txt",
|
|
"yuqei-infra/requirements/ai-runtime-linux-x86_64-py312.txt",
|
|
)
|
|
_WINDOWS_GIT_EXECUTABLE = (
|
|
None
|
|
if sys.platform.startswith("linux")
|
|
else (
|
|
str(Path(discovered).resolve())
|
|
if (discovered := shutil.which("git")) is not None
|
|
else None
|
|
)
|
|
)
|
|
RELEASE_CONTROL_PATHS = (
|
|
".github/workflows/build-test-quality-gate.yml",
|
|
".github/workflows/security-quality-gate.yml",
|
|
"yuqei-infra/scripts/build-release-candidate-manifest.py",
|
|
"yuqei-infra/scripts/build-release-worktree-ownership.py",
|
|
"yuqei-infra/compose/docker-compose.production.offline.yml",
|
|
"yuqei-infra/deploy/preflight-production.py",
|
|
"yuqei-infra/deploy/verify-production-compose.py",
|
|
"yuqei-infra/deploy/release-package.py",
|
|
"yuqei-infra/deploy/plan-first-release-snapshot-restore.py",
|
|
"yuqei-infra/deploy/plan-rollback-rehearsal.py",
|
|
"yuqei-infra/release-trust/third-party-materials-baseline.json",
|
|
"yuqei-infra/release/linux-release-security-policy.v1.json",
|
|
"yuqei-infra/scripts/linux_release_common.py",
|
|
"yuqei-infra/scripts/run-linux-release-build.py",
|
|
"yuqei-infra/scripts/build-installable-release.py",
|
|
"yuqei-infra/scripts/build-infrastructure-image-bundle.py",
|
|
"yuqei-infra/scripts/build-third-party-materials.py",
|
|
"yuqei-infra/tests/test_production_compose_preflight.py",
|
|
"yuqei-infra/tests/test_production_compose_merge_gate.py",
|
|
"yuqei-infra/tests/test_release_package_executor.py",
|
|
"yuqei-infra/tests/test_linux_release_build_tools.py",
|
|
"yuqei-infra/tests/test_installable_release_assembler_hardening.py",
|
|
"yuqei-infra/tests/test_infrastructure_image_bundle.py",
|
|
"yuqei-infra/tests/test_first_release_snapshot_restore_planner.py",
|
|
"yuqei-infra/tests/test_rollback_rehearsal_planner.py",
|
|
"yuqei-infra/tests/test_third_party_materials.py",
|
|
"yuqei-infra/tests/test_release_candidate_manifest_tools.py",
|
|
)
|
|
CONTRACT_LOCK_PATH = REQUIREMENT_LOCK_PATHS[0]
|
|
CONTRACT_DOCKERFILE_PATH = "yuqei-infra/docker/contract-api.Dockerfile"
|
|
REQUIRED_PATHS = (
|
|
"package.json",
|
|
"package-lock.json",
|
|
"tsconfig.base.json",
|
|
*PYPROJECT_PATHS,
|
|
*OPENAPI_PATHS,
|
|
*PROMPT_DEFINITION_PATHS,
|
|
*REQUIREMENT_LOCK_PATHS,
|
|
*RELEASE_CONTROL_PATHS,
|
|
BASELINE_PATH,
|
|
"yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py",
|
|
"yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py",
|
|
"yuqei-ai-sdk-python/src/yuqei_sdk/contract_drafting.py",
|
|
"yuqei-ai-sdk-python/src/yuqei_sdk/draft_semantic_review.py",
|
|
"yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py",
|
|
"yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/config.py",
|
|
"yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/drafting_prompt_modules.py",
|
|
"yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/review_scene_prompts.py",
|
|
"yuqei-ai-platform/services/worker/src/yuqei_worker/main.py",
|
|
"yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/main.py",
|
|
"yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py",
|
|
"yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/contract_templates.py",
|
|
"yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/contract_scene_assets.py",
|
|
"yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/drafting.py",
|
|
"yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/review.py",
|
|
"yuqei-contract-platform/services/contract-api/migrations/versions/20260909_0079_add_party_profiles.py",
|
|
"yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260826_0028_add_stratified_quality_reviews.py",
|
|
"yuqei-contract-platform/apps/contract-web/package.json",
|
|
"yuqei-contract-platform/apps/contract-web/src/app/page.tsx",
|
|
"yuqei-contract-platform/apps/contract-web/src/app/contracts/[id]/page.tsx",
|
|
"yuqei-contract-platform/apps/contract-web/src/app/api/contract-platform/drafting-workspaces/[[...segments]]/route.ts",
|
|
"yuqei-contract-platform/apps/contract-web/src/app/api/ai-platform/prompt-templates/route.ts",
|
|
"yuqei-contract-platform/packages/design-system/package.json",
|
|
"yuqei-contract-platform/packages/design-system/src/index.ts",
|
|
"yuqei-infra/docker/contract-api.Dockerfile",
|
|
"yuqei-infra/docker/ai-platform-api.Dockerfile",
|
|
"yuqei-infra/docker/worker.Dockerfile",
|
|
"yuqei-infra/docker/contract-web.Dockerfile",
|
|
"yuqei-infra/compose/docker-compose.yml",
|
|
"yuqei-infra/compose/docker-compose.production.yml",
|
|
"yuqei-infra/nginx/templates/default.conf.template",
|
|
)
|
|
REQUIRED_PREFIXES = (
|
|
"yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/template_assets/v1_contract_templates/",
|
|
"yuqei-contract-platform/services/contract-api/migrations/versions/",
|
|
"yuqei-ai-platform/services/ai-platform-api/migrations/versions/",
|
|
"yuqei-contract-platform/apps/contract-web/src/app/api/",
|
|
)
|
|
SECRET_NAMED_SOURCE_SUFFIXES = {
|
|
".cjs",
|
|
".go",
|
|
".java",
|
|
".js",
|
|
".mjs",
|
|
".py",
|
|
".pyi",
|
|
".rs",
|
|
".ts",
|
|
".tsx",
|
|
}
|
|
SOURCE_TREE_COMPONENTS = {"src", "tests", "migrations"}
|
|
|
|
|
|
class ManifestError(RuntimeError):
|
|
"""Raised when a candidate cannot truthfully receive a source manifest."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TreeEntry:
|
|
mode: str
|
|
object_id: str
|
|
size: int
|
|
path: str
|
|
|
|
|
|
def _controlled_git_environment() -> dict[str, str]:
|
|
if sys.platform.startswith("linux"):
|
|
environment = {
|
|
"HOME": "/var/empty",
|
|
"LANG": "C.UTF-8",
|
|
"LC_ALL": "C.UTF-8",
|
|
"PATH": "/usr/bin:/bin",
|
|
}
|
|
else:
|
|
environment = {
|
|
key: os.environ[key]
|
|
for key in ("SYSTEMROOT", "WINDIR", "TEMP", "TMP")
|
|
if key in os.environ
|
|
}
|
|
if not _WINDOWS_GIT_EXECUTABLE:
|
|
raise ManifestError("Git executable is unavailable")
|
|
environment["PATH"] = str(Path(_WINDOWS_GIT_EXECUTABLE).parent)
|
|
environment.update({"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"})
|
|
environment.update(
|
|
{
|
|
"GIT_CONFIG_NOSYSTEM": "1",
|
|
"GIT_NO_REPLACE_OBJECTS": "1",
|
|
"GIT_OPTIONAL_LOCKS": "0",
|
|
"GIT_TERMINAL_PROMPT": "0",
|
|
}
|
|
)
|
|
return environment
|
|
|
|
|
|
def _git_command(repo: Path, *args: str) -> list[str]:
|
|
git_path = "/usr/bin/git" if sys.platform.startswith("linux") else _WINDOWS_GIT_EXECUTABLE
|
|
if not git_path:
|
|
raise ManifestError("Git executable is unavailable")
|
|
return [str(git_path), "--no-replace-objects", "-C", str(repo), *args]
|
|
|
|
|
|
def _git(repo: Path, *args: str, binary: bool = False) -> bytes | str:
|
|
completed = subprocess.run(
|
|
_git_command(repo, *args),
|
|
check=False,
|
|
capture_output=True,
|
|
env=_controlled_git_environment(),
|
|
timeout=30,
|
|
)
|
|
if completed.returncode:
|
|
message = completed.stderr.decode("utf-8", "replace").strip()
|
|
raise ManifestError(f"git {' '.join(args)} failed: {message}")
|
|
return completed.stdout if binary else completed.stdout.decode("utf-8", "strict").strip()
|
|
|
|
|
|
def _is_within(path: Path, parent: Path) -> bool:
|
|
try:
|
|
path.relative_to(parent)
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def validate_candidate_identity(repo: Path, commit: str, output: Path) -> str:
|
|
if not FULL_COMMIT_RE.fullmatch(commit):
|
|
raise ManifestError("--commit must be a full hexadecimal commit object id; names such as HEAD are refused")
|
|
replacement_refs = str(
|
|
_git(repo, "for-each-ref", "--format=%(refname)", "refs/replace/")
|
|
)
|
|
if replacement_refs:
|
|
raise ManifestError("repository contains forbidden refs/replace entries")
|
|
resolved = str(_git(repo, "rev-parse", "--verify", f"{commit}^{{commit}}"))
|
|
if resolved != commit:
|
|
raise ManifestError(f"commit object id did not resolve exactly: {commit} -> {resolved}")
|
|
head = str(_git(repo, "rev-parse", "HEAD"))
|
|
if head != commit:
|
|
raise ManifestError(f"HEAD {head} does not equal explicit candidate commit {commit}")
|
|
dirty = bytes(_git(repo, "status", "--porcelain=v1", "-z", "-uall", binary=True))
|
|
if dirty:
|
|
raise ManifestError("working tree is dirty; a working-tree snapshot must not be presented as a commit manifest")
|
|
if _is_within(output.resolve(), repo.resolve()):
|
|
raise ManifestError("manifest output must be outside the repository so generation does not dirty the candidate")
|
|
return resolved
|
|
|
|
|
|
def list_commit_tree(repo: Path, commit: str) -> list[TreeEntry]:
|
|
raw = bytes(_git(repo, "ls-tree", "-r", "-z", "--long", commit, binary=True))
|
|
entries: list[TreeEntry] = []
|
|
for record in raw.split(b"\0"):
|
|
if not record:
|
|
continue
|
|
metadata, raw_path = record.split(b"\t", 1)
|
|
mode, object_type, object_id, raw_size = metadata.decode("ascii").split()
|
|
if object_type != "blob" or raw_size == "-":
|
|
raise ManifestError(f"unsupported tree entry for {raw_path!r}: {object_type} {raw_size}")
|
|
entries.append(
|
|
TreeEntry(
|
|
mode=mode,
|
|
object_id=object_id,
|
|
size=int(raw_size),
|
|
path=raw_path.decode("utf-8", "strict"),
|
|
)
|
|
)
|
|
entries.sort(key=lambda item: item.path.encode("utf-8"))
|
|
return entries
|
|
|
|
|
|
def read_commit_blobs(repo: Path, entries: Iterable[TreeEntry]) -> dict[str, bytes]:
|
|
process = subprocess.Popen(
|
|
_git_command(repo, "cat-file", "--batch"),
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
env=_controlled_git_environment(),
|
|
)
|
|
assert process.stdin is not None and process.stdout is not None and process.stderr is not None
|
|
blobs: dict[str, bytes] = {}
|
|
try:
|
|
for entry in entries:
|
|
process.stdin.write(entry.object_id.encode("ascii") + b"\n")
|
|
process.stdin.flush()
|
|
header = process.stdout.readline().rstrip(b"\n").decode("ascii")
|
|
parts = header.split()
|
|
if len(parts) != 3 or parts[0] != entry.object_id or parts[1] != "blob":
|
|
raise ManifestError(f"unexpected cat-file header for {entry.path}: {header}")
|
|
blob_size = int(parts[2])
|
|
content = process.stdout.read(blob_size)
|
|
terminator = process.stdout.read(1)
|
|
if terminator != b"\n" or len(content) != blob_size or blob_size != entry.size:
|
|
raise ManifestError(f"blob size mismatch for {entry.path}")
|
|
blobs[entry.path] = content
|
|
finally:
|
|
if process.stdin:
|
|
process.stdin.close()
|
|
process.wait(timeout=30)
|
|
if process.returncode:
|
|
raise ManifestError(process.stderr.read().decode("utf-8", "replace"))
|
|
return blobs
|
|
|
|
|
|
def forbidden_candidate_path(path: str) -> str | None:
|
|
pure = PurePosixPath(path)
|
|
lowered = path.casefold()
|
|
parts = [part.casefold() for part in pure.parts]
|
|
name = pure.name.casefold()
|
|
safe_template = any(token in name for token in (".example", ".sample", ".template", ".dist"))
|
|
if any(part in {"node_modules", "__pycache__", ".pytest_cache", ".mypy_cache", ".next", ".runtime"} for part in parts):
|
|
return "cache_or_runtime_directory"
|
|
if "dist-test" in parts or pure.suffix.casefold() in {".whl", ".sqlite", ".sqlite3", ".db", ".log", ".pid"}:
|
|
return "local_binary_or_runtime_file"
|
|
if pure.suffix.casefold() in {".pem", ".p12", ".pfx", ".jks", ".kdbx"}:
|
|
return "private_key_or_keystore_path"
|
|
if name in {"id_rsa", "id_ed25519", ".npmrc", ".pypirc", "credentials.json", "service-account.json"}:
|
|
return "credential_path"
|
|
if (name == ".env" or name.startswith(".env.")) and not safe_template:
|
|
return "non_template_environment_file"
|
|
if re.search(r"(?:^|/)(?:secrets?|credentials?)(?:[._-]|/)", lowered) and not safe_template:
|
|
source_named_for_secret_domain = (
|
|
pure.suffix.casefold() in SECRET_NAMED_SOURCE_SUFFIXES
|
|
and bool(SOURCE_TREE_COMPONENTS.intersection(parts))
|
|
)
|
|
if not source_named_for_secret_domain:
|
|
return "secret_like_path"
|
|
return None
|
|
|
|
|
|
def validate_tree_paths(entries: list[TreeEntry]) -> None:
|
|
paths = {entry.path for entry in entries}
|
|
missing = sorted(set(REQUIRED_PATHS) - paths)
|
|
missing_prefixes = sorted(prefix for prefix in REQUIRED_PREFIXES if not any(path.startswith(prefix) for path in paths))
|
|
forbidden = sorted((entry.path, reason) for entry in entries if (reason := forbidden_candidate_path(entry.path)))
|
|
if missing or missing_prefixes or forbidden:
|
|
details = {"missing_paths": missing, "missing_prefixes": missing_prefixes, "forbidden_paths": forbidden}
|
|
raise ManifestError("candidate tree validation failed: " + json.dumps(details, ensure_ascii=False))
|
|
|
|
|
|
def _sha256(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def _parse_lock(content: bytes) -> dict[str, Any]:
|
|
text = content.decode("utf-8")
|
|
requirements = re.findall(r"^([A-Za-z0-9_.-]+(?:\[[^\]]+\])?==[^ ]+) \\$", text, re.MULTILINE)
|
|
hashes = re.findall(r"^\s+--hash=sha256:([0-9a-f]{64})$", text, re.MULTILINE)
|
|
if not requirements or len(requirements) != len(hashes):
|
|
raise ManifestError("Python requirement lock is not fully exact/hash pinned")
|
|
return {"package_count": len(requirements), "requirements": requirements, "hash_count": len(hashes)}
|
|
|
|
|
|
def dependency_summary(blobs: dict[str, bytes]) -> dict[str, Any]:
|
|
python_projects = []
|
|
for path in PYPROJECT_PATHS:
|
|
data = tomllib.loads(blobs[path].decode("utf-8"))
|
|
project = data["project"]
|
|
python_projects.append(
|
|
{
|
|
"path": path,
|
|
"name": project["name"],
|
|
"requires_python": project.get("requires-python"),
|
|
"dependencies": project.get("dependencies", []),
|
|
"sha256": _sha256(blobs[path]),
|
|
}
|
|
)
|
|
root_package = json.loads(blobs["package.json"])
|
|
package_lock = json.loads(blobs["package-lock.json"])
|
|
locks = []
|
|
for path in REQUIREMENT_LOCK_PATHS:
|
|
locks.append({"path": path, "sha256": _sha256(blobs[path]), **_parse_lock(blobs[path])})
|
|
return {
|
|
"python_projects": python_projects,
|
|
"python_locks": locks,
|
|
"node": {
|
|
"package_json_sha256": _sha256(blobs["package.json"]),
|
|
"package_lock_sha256": _sha256(blobs["package-lock.json"]),
|
|
"package_manager": root_package.get("packageManager"),
|
|
"workspaces": root_package.get("workspaces", []),
|
|
"lockfile_version": package_lock.get("lockfileVersion"),
|
|
"locked_package_entries": len(package_lock.get("packages", {})),
|
|
},
|
|
}
|
|
|
|
|
|
def runtime_compatibility_summary(blobs: dict[str, bytes]) -> dict[str, Any]:
|
|
contract_lock = blobs[CONTRACT_LOCK_PATH].decode("utf-8")
|
|
contract_dockerfile = blobs[CONTRACT_DOCKERFILE_PATH].decode("utf-8")
|
|
required_lock_markers = (
|
|
"glibc >= 2.28",
|
|
"manylinux_2_28_x86_64",
|
|
"manylinux2014_x86_64 fallback",
|
|
"pillow==12.3.0",
|
|
)
|
|
required_docker_markers = (
|
|
"YUQEI_MIN_GLIBC=2.28",
|
|
"platform.libc_ver()",
|
|
"actual >= minimum",
|
|
)
|
|
missing_lock_markers = [marker for marker in required_lock_markers if marker not in contract_lock]
|
|
missing_docker_markers = [marker for marker in required_docker_markers if marker not in contract_dockerfile]
|
|
if missing_lock_markers or missing_docker_markers:
|
|
raise ManifestError(
|
|
"Contract runtime compatibility policy is incomplete: "
|
|
+ json.dumps(
|
|
{
|
|
"missing_lock_markers": missing_lock_markers,
|
|
"missing_docker_markers": missing_docker_markers,
|
|
},
|
|
ensure_ascii=False,
|
|
)
|
|
)
|
|
return {
|
|
"python": "CPython 3.12",
|
|
"operating_system": "Linux",
|
|
"architecture": "x86_64",
|
|
"minimum_glibc": "2.28",
|
|
"wheel_platform_preference": ["manylinux_2_28_x86_64", "manylinux2014_x86_64"],
|
|
"build_guard": {
|
|
"path": CONTRACT_DOCKERFILE_PATH,
|
|
"policy": "platform.libc_ver() must report glibc >= 2.28 before dependency installation",
|
|
"sha256": _sha256(blobs[CONTRACT_DOCKERFILE_PATH]),
|
|
},
|
|
"contract_lock": {
|
|
"path": CONTRACT_LOCK_PATH,
|
|
"pillow_security_floor": "12.3.0",
|
|
"sha256": _sha256(blobs[CONTRACT_LOCK_PATH]),
|
|
},
|
|
}
|
|
|
|
|
|
def _openapi_counts(content: bytes) -> tuple[int, int]:
|
|
text = content.decode("utf-8")
|
|
path_count = len(re.findall(r"^ /[^\n:]+(?:\{[^\n]+\})?:\s*$", text, re.MULTILINE))
|
|
schema_block = text.split("\n schemas:\n", 1)
|
|
schema_count = 0
|
|
if len(schema_block) == 2:
|
|
for line in schema_block[1].splitlines():
|
|
if line and not line.startswith(" "):
|
|
break
|
|
if re.match(r"^ [A-Za-z0-9_.-]+:\s*$", line):
|
|
schema_count += 1
|
|
return path_count, schema_count
|
|
|
|
|
|
def openapi_summary(blobs: dict[str, bytes]) -> list[dict[str, Any]]:
|
|
result = []
|
|
for path in OPENAPI_PATHS:
|
|
paths, schemas = _openapi_counts(blobs[path])
|
|
result.append(
|
|
{"path": path, "sha256": _sha256(blobs[path]), "size": len(blobs[path]), "paths": paths, "schemas": schemas}
|
|
)
|
|
return result
|
|
|
|
|
|
def _literal_assignment(tree: ast.AST, name: str) -> Any:
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
|
|
return ast.literal_eval(node.value)
|
|
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == name:
|
|
return ast.literal_eval(node.value)
|
|
return None
|
|
|
|
|
|
def _prompt_module_records(payload: Any, path: str) -> list[dict[str, Any]]:
|
|
records = payload.get("modules") if isinstance(payload, dict) else payload
|
|
if not isinstance(records, list) or not all(isinstance(item, dict) for item in records):
|
|
raise ManifestError(f"Prompt definition must be a list or an object with a modules list: {path}")
|
|
return records
|
|
|
|
|
|
def migration_summary(blobs: dict[str, bytes]) -> list[dict[str, Any]]:
|
|
roots = (
|
|
"yuqei-contract-platform/services/contract-api/migrations/versions/",
|
|
"yuqei-ai-platform/services/ai-platform-api/migrations/versions/",
|
|
)
|
|
groups = []
|
|
for root in roots:
|
|
migrations = []
|
|
for path in sorted(path for path in blobs if path.startswith(root) and path.endswith(".py")):
|
|
tree = ast.parse(blobs[path].decode("utf-8"), filename=path)
|
|
revision = _literal_assignment(tree, "revision")
|
|
down_revision = _literal_assignment(tree, "down_revision")
|
|
if not isinstance(revision, str):
|
|
raise ManifestError(f"migration has no literal revision: {path}")
|
|
parents = [] if down_revision is None else [down_revision] if isinstance(down_revision, str) else list(down_revision)
|
|
migrations.append({"path": path, "revision": revision, "down_revisions": parents, "sha256": _sha256(blobs[path])})
|
|
revisions = {item["revision"] for item in migrations}
|
|
referenced = {parent for item in migrations for parent in item["down_revisions"] if parent in revisions}
|
|
groups.append({"root": root, "count": len(migrations), "heads": sorted(revisions - referenced), "migrations": migrations})
|
|
return groups
|
|
|
|
|
|
def asset_and_prompt_summary(blobs: dict[str, bytes]) -> dict[str, Any]:
|
|
baseline = json.loads(blobs[BASELINE_PATH])
|
|
asset_ids = [item["id"] for item in baseline.get("assets", [])]
|
|
baseline_prompt_keys = [
|
|
key
|
|
for section in baseline.get("prompt_modules", {}).values()
|
|
for key in section.get("keys", [])
|
|
]
|
|
prompt_files = []
|
|
actual_prompt_keys: list[str] = []
|
|
for path in PROMPT_DEFINITION_PATHS:
|
|
modules = _prompt_module_records(json.loads(blobs[path]), path)
|
|
keys = [item["key"] for item in modules]
|
|
prompt_files.append({"path": path, "sha256": _sha256(blobs[path]), "keys": keys})
|
|
actual_prompt_keys.extend(keys)
|
|
if len(asset_ids) != 60 or len(set(asset_ids)) != 60:
|
|
raise ManifestError(f"delivery baseline must contain 60 unique assets, found {len(asset_ids)}/{len(set(asset_ids))}")
|
|
if len(actual_prompt_keys) != 14 or len(set(actual_prompt_keys)) != 14:
|
|
raise ManifestError("Prompt definition files must contain 14 unique modules")
|
|
if set(baseline_prompt_keys) != set(actual_prompt_keys):
|
|
raise ManifestError("delivery baseline Prompt keys differ from source definition files")
|
|
asset_prefix = "yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/template_assets/v1_contract_templates/"
|
|
asset_files = sorted(path for path in blobs if path.startswith(asset_prefix))
|
|
return {
|
|
"baseline_path": BASELINE_PATH,
|
|
"baseline_sha256": _sha256(blobs[BASELINE_PATH]),
|
|
"asset_count": len(asset_ids),
|
|
"asset_ids": asset_ids,
|
|
"asset_source_file_count": len(asset_files),
|
|
"asset_source_tree_sha256": _sha256(
|
|
"\n".join(f"{path}\0{_sha256(blobs[path])}" for path in asset_files).encode("utf-8")
|
|
),
|
|
"prompt_module_count": len(actual_prompt_keys),
|
|
"prompt_keys": actual_prompt_keys,
|
|
"prompt_definition_files": prompt_files,
|
|
}
|
|
|
|
|
|
def configuration_summary(blobs: dict[str, bytes]) -> list[dict[str, Any]]:
|
|
selected = []
|
|
for path in sorted(blobs):
|
|
name = PurePosixPath(path).name
|
|
if (
|
|
name.endswith("Dockerfile")
|
|
or path.startswith("yuqei-infra/compose/")
|
|
or path.startswith("yuqei-infra/nginx/")
|
|
or path.startswith("yuqei-infra/deploy/")
|
|
or path.startswith("yuqei-infra/release/")
|
|
or path in RELEASE_CONTROL_PATHS
|
|
or name in {"pyproject.toml", "package.json", "package-lock.json", "alembic.ini", "tsconfig.base.json"}
|
|
or path in REQUIREMENT_LOCK_PATHS
|
|
):
|
|
selected.append({"path": path, "sha256": _sha256(blobs[path]), "size": len(blobs[path])})
|
|
return selected
|
|
|
|
|
|
def runtime_graph_summary(blobs: dict[str, bytes]) -> list[dict[str, Any]]:
|
|
components = (
|
|
("python_sdk", ("yuqei-ai-sdk-python/src/yuqei_sdk/",)),
|
|
("ai_api_worker", ("yuqei-ai-platform/services/ai-platform-api/src/", "yuqei-ai-platform/services/worker/src/")),
|
|
("contract_api", ("yuqei-contract-platform/services/contract-api/src/",)),
|
|
("web_bff_design_system", ("yuqei-contract-platform/apps/contract-web/src/", "yuqei-contract-platform/packages/design-system/src/")),
|
|
("openapi", OPENAPI_PATHS),
|
|
("migrations", ("yuqei-contract-platform/services/contract-api/migrations/", "yuqei-ai-platform/services/ai-platform-api/migrations/")),
|
|
(
|
|
"deployment_and_release_controls",
|
|
(
|
|
"yuqei-infra/docker/",
|
|
"yuqei-infra/compose/",
|
|
"yuqei-infra/nginx/",
|
|
"yuqei-infra/deploy/",
|
|
"yuqei-infra/release/",
|
|
"yuqei-infra/scripts/linux_release_common.py",
|
|
"yuqei-infra/scripts/run-linux-release-build.py",
|
|
"yuqei-infra/scripts/build-installable-release.py",
|
|
*RELEASE_CONTROL_PATHS,
|
|
),
|
|
),
|
|
)
|
|
result = []
|
|
for name, prefixes in components:
|
|
paths = sorted(path for path in blobs if any(path == prefix or path.startswith(prefix) for prefix in prefixes))
|
|
result.append(
|
|
{
|
|
"component": name,
|
|
"file_count": len(paths),
|
|
"total_size": sum(len(blobs[path]) for path in paths),
|
|
"content_set_sha256": _sha256(
|
|
"\n".join(f"{path}\0{_sha256(blobs[path])}" for path in paths).encode("utf-8")
|
|
),
|
|
"prefixes": list(prefixes),
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def build_manifest(repo: Path, commit: str, output: Path) -> dict[str, Any]:
|
|
validate_candidate_identity(repo, commit, output)
|
|
entries = list_commit_tree(repo, commit)
|
|
validate_tree_paths(entries)
|
|
blobs = read_commit_blobs(repo, entries)
|
|
file_records = [
|
|
{
|
|
"path": entry.path,
|
|
"mode": entry.mode,
|
|
"git_blob_oid": entry.object_id,
|
|
"size": entry.size,
|
|
"sha256": _sha256(blobs[entry.path]),
|
|
}
|
|
for entry in entries
|
|
]
|
|
content_index = "\n".join(
|
|
f"{item['path']}\0{item['mode']}\0{item['size']}\0{item['sha256']}" for item in file_records
|
|
).encode("utf-8")
|
|
commit_timestamp = str(_git(repo, "show", "-s", "--format=%cI", commit))
|
|
manifest = {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"candidate": {
|
|
"commit": commit,
|
|
"commit_timestamp": commit_timestamp,
|
|
"tree": str(_git(repo, "rev-parse", f"{commit}^{{tree}}")),
|
|
"worktree_was_clean": True,
|
|
"identity_policy": "full_commit_oid_equals_HEAD",
|
|
},
|
|
"files": {
|
|
"count": len(file_records),
|
|
"total_size": sum(item["size"] for item in file_records),
|
|
"content_index_sha256": _sha256(content_index),
|
|
"items": file_records,
|
|
},
|
|
"summaries": {
|
|
"runtime_build_graph": runtime_graph_summary(blobs),
|
|
"runtime_compatibility": runtime_compatibility_summary(blobs),
|
|
"dependencies": dependency_summary(blobs),
|
|
"openapi": openapi_summary(blobs),
|
|
"migrations": migration_summary(blobs),
|
|
"configuration": configuration_summary(blobs),
|
|
"assets_and_prompts": asset_and_prompt_summary(blobs),
|
|
},
|
|
"claims_not_made": [
|
|
"No image was built or assigned a digest by this source-only tool.",
|
|
"No dependency vulnerability, deployment, database migration, publication, or legal approval is inferred.",
|
|
],
|
|
}
|
|
manifest["manifest_payload_sha256"] = _sha256(
|
|
json.dumps(manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
)
|
|
return manifest
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--repo", type=Path, default=Path.cwd())
|
|
parser.add_argument("--commit", required=True, help="full Git commit object id; symbolic names are refused")
|
|
parser.add_argument("--output", type=Path, required=True, help="must be outside the repository")
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
manifest = build_manifest(args.repo.resolve(), args.commit.casefold(), args.output.resolve())
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(
|
|
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
except ManifestError as error:
|
|
print(f"manifest_error: {error}", file=sys.stderr)
|
|
return 2
|
|
print(json.dumps({"output": str(args.output), "commit": args.commit, "files": manifest["files"]["count"]}))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|