#!/usr/bin/env python3 """Build immutable application commits without developer workspace overlays. Go versions remain authoritative in go.mod; npm versions in package-lock.json. The output manifest records the selected graph rather than creating another lock. """ import argparse import hashlib import importlib.util import io import json import os from pathlib import Path, PurePosixPath import re import shutil import subprocess import sys import tarfile import tempfile from urllib.parse import urlsplit SHA = re.compile(r"[0-9a-f]{40}\Z") IDS = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*\Z") BACKEND = "Agent-System-Backend" FRONTEND = "Agent-System-Frontend" NPM_NAME = re.compile(r"(?:@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*\Z") NPM_VERSION = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\Z") class CompositionError(Exception): pass def run(command, cwd, env, *, binary=False, quiet=False): if not quiet: print("[composition] " + " ".join(map(str, command)), flush=True) result = subprocess.run(command, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=not binary) if not quiet and not binary: sys.stdout.write(result.stdout) sys.stderr.write(result.stderr) if result.returncode: detail = result.stderr.decode(errors="replace") if binary else result.stderr raise CompositionError(f"command failed ({result.returncode}): {command[0]} {detail.strip()}") return result.stdout def digest(path): # The composer also supports standalone fixed-root wrapper fixtures. return hashlib.sha256(path.read_bytes()).hexdigest() def tree_digest(root): if root.is_symlink(): raise CompositionError(f"artifact root symlink is forbidden: {root.name}") entries = [] for path in sorted(root.rglob("*")): if path.is_symlink(): raise CompositionError(f"artifact symlink is forbidden: {path.name}") if path.is_file(): entries.append({"path": path.relative_to(root).as_posix(), "sha256": digest(path), "size": path.stat().st_size}) encoded = json.dumps(entries, sort_keys=True, separators=(",", ":")).encode() return {"sha256": hashlib.sha256(encoded).hexdigest(), "files": entries} def safe_relative(root, value): if not isinstance(value, str) or not value or "\\" in value: raise CompositionError("invalid component path") relative = PurePosixPath(value) if relative.is_absolute() or ".." in relative.parts or (root / value).resolve().is_relative_to(root) is False: raise CompositionError("component path escapes root") def validate_repo(repo): if not isinstance(repo, str) or not repo or repo.startswith("-") or any(c.isspace() for c in repo): raise CompositionError("invalid repository source") if "://" not in repo: if not Path(repo).is_absolute(): raise CompositionError("repository must be an approved URL or absolute local path") return parsed = urlsplit(repo) if parsed.scheme not in {"ssh", "https", "file"} or parsed.password or parsed.query or parsed.fragment: raise CompositionError("repository URL contains credentials or unsupported transport") if parsed.scheme == "https" and parsed.username: raise CompositionError("repository credentials are forbidden") if parsed.scheme == "file" and parsed.netloc not in {"", "localhost"}: raise CompositionError("file repository must be local") def tooling_identity(root, environment=None): """Bind executed tools before importing acquisition configuration.""" env = (os.environ if environment is None else environment).copy() for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"): env.pop(key, None) commit = run(["git", "rev-parse", "HEAD"], root, env, quiet=True).strip() result = {"root_commit": commit, "files": {}} for relative in ("scripts/build-composition.py", "scripts/workspace/transport.py"): executing = Path(__file__).resolve().parent / relative.removeprefix("scripts/") fixed = subprocess.run(["git", "show", f"{commit}:{relative}"], cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if fixed.returncode == 0: if executing.is_symlink() or executing.read_bytes() != fixed.stdout: raise CompositionError(f"executing tooling differs from committed source: {relative}") mode = "committed-root" elif (root / relative).exists(): raise CompositionError(f"root tooling has no committed identity: {relative}") else: # A standalone consumer fixture can use an external composer. Its # exact tool bytes are a separate identity, never the consumer SHA. mode = "external-tool" result["files"][relative] = {"sha256": digest(executing), "source": mode} return result def inputs(root, env): commit = run(["git", "rev-parse", "HEAD"], root, env, quiet=True).strip() if not SHA.fullmatch(commit): raise CompositionError("root must have a committed Git revision") documents = {} hashes = {} for name in ("components.json", "components.lock.json"): committed = run(["git", "show", f"{commit}:{name}"], root, env, binary=True, quiet=True) path = root / name if path.is_symlink() or path.read_bytes() != committed: raise CompositionError(f"{name} differs from committed root {commit}") documents[name] = json.loads(committed) hashes[name] = hashlib.sha256(committed).hexdigest() manifest, lock = documents["components.json"], documents["components.lock.json"] if manifest.get("schema") != 1 or not isinstance(manifest.get("components"), list): raise CompositionError("unsupported components schema") if lock.get("schema") != 1 or not isinstance(lock.get("components"), dict): raise CompositionError("unsupported component lock schema") components = {} for item in manifest["components"]: name = item.get("id") if not isinstance(name, str) or not IDS.fullmatch(name) or name in components: raise CompositionError("invalid or duplicate component id") if item.get("path") != f".workspace/{name}": raise CompositionError(f"invalid component path: {name}") safe_relative(root, item["path"]) if item.get("legacy_path"): safe_relative(root, item["legacy_path"]) validate_repo(item.get("repo")) for module in item.get("modules", []): safe_relative(root, module.get("directory", ".")) components[name] = item for name, value in lock["components"].items(): if name not in components or not isinstance(value, dict) or not SHA.fullmatch(value.get("commit", "")): raise CompositionError(f"invalid locked component: {name}") if components[name].get("role") in {"go-library", "npm-library"}: raise CompositionError(f"language library cannot have a competing source lock: {name}") for name in (BACKEND, FRONTEND): if name not in components or components[name].get("role") != "application" or name not in lock["components"]: raise CompositionError(f"missing fixed application source: {name}") return commit, components, lock["components"], hashes def execution_environment(env): """Acquisition-only authentication/transport never reaches npm/Go candidate code.""" clean = env.copy() for key in list(clean): upper = key.upper() if (any(word in upper for word in ("TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "API_KEY", "ACCESS_KEY")) or upper.startswith(("GIT_CONFIG_", "GIT_SSH", "NPM_CONFIG_", "BASH_FUNC_")) or upper in {"SSH_AUTH_SOCK", "SSH_AGENT_PID", "NODE_OPTIONS", "BASH_ENV", "ENV", "GOFLAGS", "GIT_ASKPASS", "SSH_ASKPASS", "CONTRACTS_TARBALL", "UI_TARBALL"}): del clean[key] clean.update(GOWORK="off", GOTOOLCHAIN="local", GOFLAGS="", CGO_ENABLED="0") # Cache location is transport-independent state, shared by acquisition and # offline consumers. Other npm settings can carry credentials or hooks. for key in ("npm_config_cache", "NPM_CONFIG_CACHE"): if env.get(key): clean[key] = env[key] return clean def export_source(repo, commit, destination, temporary, env, *, abbreviated=False): validate_repo(repo) clone = temporary / (destination.name + "-git") run(["git", "clone", "--no-checkout", "--no-hardlinks", "--", repo, str(clone)], temporary, env) exists = subprocess.run(["git", "cat-file", "-e", commit + "^{commit}"], cwd=clone, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0 if not exists: run(["git", "fetch", "--no-tags", "origin", commit], clone, env) actual = run(["git", "rev-parse", commit + "^{commit}"], clone, env, quiet=True).strip() if not SHA.fullmatch(actual) or (actual != commit and not (abbreviated and len(commit) == 12 and actual.startswith(commit))): raise CompositionError("application revision is not an immutable full commit") archive = run(["git", "archive", "--format=tar", actual], clone, env, binary=True, quiet=True) extract_archive(archive, destination) return {"repo": repo, "commit": actual, "archive_sha256": hashlib.sha256(archive).hexdigest()} def extract_archive(archive, destination): destination.mkdir() with tarfile.open(fileobj=io.BytesIO(archive)) as tar: for entry in tar.getmembers(): safe_relative(destination, entry.name) if not (entry.isfile() or entry.isdir()): raise CompositionError(f"source archive contains an unsupported link: {entry.name}") tar.extractall(destination, filter="data") def version(manifest, lock, *, backend_commit=None, resolved_go=None): resolved = json.dumps({"go": manifest["go"] if resolved_go is None else resolved_go, "npm_lock": manifest["npm"]["lock_sha256"]}, sort_keys=True, separators=(",", ":")).encode() return (manifest["root"]["commit"][:12] + "+backend=" + (backend_commit or lock[BACKEND]["commit"])[:12] + "+frontend=" + lock[FRONTEND]["commit"][:12] + "+deps=" + hashlib.sha256(resolved).hexdigest()[:12]) def json_stream(output): decoder = json.JSONDecoder() result = [] while output.strip(): value, end = decoder.raw_decode(output.lstrip()) result.append(value) output = output.lstrip()[end:] return result def go_dependencies(source, fetch_env): document = json.loads(run(["go", "mod", "edit", "-json"], source, fetch_env, quiet=True)) for replacement in document.get("Replace") or []: if not replacement["New"].get("Version"): raise CompositionError("formal Go build forbids local directory replace") original = {name: (source / name).read_bytes() if (source / name).exists() else None for name in ("go.mod", "go.sum")} # Tidy locks need sums for consumed packages, not unused transitive zip files. # Load actual build/test metadata before ending acquisition; no tests execute. # Download may normalize sum lines even for a valid readonly consumer. Keep # that package-manager bookkeeping in an identical disposable manifest. with tempfile.TemporaryDirectory(prefix="composition-go-prefetch-") as directory: isolated = Path(directory) for name, content in original.items(): if content is not None: (isolated / name).write_bytes(content) downloads = json_stream(run(["go", "mod", "download", "-json"], isolated, fetch_env, quiet=True)) run(["go", "list", "-mod=readonly", "-deps", "-test", "./..."], source, fetch_env | {"CGO_ENABLED": "0"}, quiet=True) run(["go", "mod", "verify"], source, fetch_env) modules = json_stream(run(["go", "list", "-mod=readonly", "-m", "-json", "all"], source, fetch_env, quiet=True)) selected = [] for module in modules: item = {key: module[key] for key in ("Path", "Version", "Main", "Sum", "GoModSum", "GoVersion") if key in module} if "Replace" in module: replacement = module["Replace"] if not replacement.get("Version"): raise CompositionError("resolved Go graph contains a local replace") item["Replace"] = {key: replacement[key] for key in ("Path", "Version", "Sum", "GoModSum") if key in replacement} selected.append(item) for name, content in original.items(): actual = (source / name).read_bytes() if (source / name).exists() else None if actual != content: raise CompositionError(f"Go acquisition changed committed {name}; update the consumer first") return {"modules": selected, "go_mod_sha256": digest(source / "go.mod"), "go_sum_sha256": digest(source / "go.sum") if (source / "go.sum").exists() else None, "downloads": [{key: value[key] for key in ("Path", "Version", "Sum", "GoModSum", "Origin") if key in value} for value in downloads]} def npm_registry_package(location, package): resolved = package.get("resolved", "") if not isinstance(resolved, str): raise CompositionError("invalid npm lock dependency") if not resolved.startswith(("https://", "http://")): return None registry = urlsplit(resolved) if (registry.scheme != "https" or registry.netloc != "registry.npmjs.org" or registry.username or registry.password or registry.query or registry.fragment): raise CompositionError("npm tarball source is outside the reviewed public registry") name = package.get("name") or location.rsplit("node_modules/", 1)[-1] version = package.get("version", "") if (not isinstance(name, str) or not NPM_NAME.fullmatch(name) or not isinstance(version, str) or not NPM_VERSION.fullmatch(version)): raise CompositionError("invalid locked registry package name or version") return name + "@" + version def npm_dependencies(source, components, fetch_env): path = source / "package-lock.json" lock = json.loads(path.read_text()) if lock.get("lockfileVersion", 0) < 2 or not isinstance(lock.get("packages"), dict): raise CompositionError("frontend requires a complete npm lock") approved = {component["repo"] for component in components.values()} packages = {} registry_packages = [] for name, package in lock["packages"].items(): if name: safe_relative(source, name) registry_package = npm_registry_package(name, package) if registry_package: registry_packages.append((name, registry_package)) if package.get("link"): raise CompositionError("formal npm build forbids workspace links") for value in [package.get("resolved", ""), *package.get("dependencies", {}).values()]: if not isinstance(value, str): raise CompositionError("invalid npm lock dependency") if value.startswith(("file:", "link:", "workspace:")): raise CompositionError("formal npm build forbids local dependency overlays") if value.startswith(("git+", "git:", "ssh:")): repo, sep, revision = value.removeprefix("git+").rpartition("#") if not sep or not SHA.fullmatch(revision) or repo not in approved: raise CompositionError("npm Git dependency must use an approved source and full commit") packages[name] = {key: package[key] for key in ("version", "resolved", "integrity") if key in package} before = digest(path) run(["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"], source, fetch_env) # npm ci normally stores tarballs without the packuments required when a # disposable packed-package consumer re-resolves peers offline. Fetch the # installed locked versions before candidate execution, from a fixed public # registry with empty config and no acquisition credentials or candidate rc. metadata_env = execution_environment(fetch_env) with tempfile.TemporaryDirectory(prefix="composition-npm-metadata-") as directory: isolated = Path(directory) global_config = isolated / "empty-global.npmrc" global_config.write_text("") selected = {spec for location, spec in registry_packages if (source / location).is_dir()} if selected: run(["npm", "cache", "add", *sorted(selected), "--ignore-scripts", "--registry=https://registry.npmjs.org", "--userconfig=/dev/null", "--globalconfig=" + str(global_config)], isolated, metadata_env) if digest(path) != before: raise CompositionError("npm acquisition changed committed lock") result = {"lock_sha256": before, "packages": packages} contract = source / "node_modules/@agent-system/contracts" if contract.is_dir(): result["contracts"] = tree_digest(contract) return result def frontend_assets(source, clean_env): dist = source / "dist" if dist.exists(): shutil.rmtree(dist) run(["npm", "run", "build"], source, clean_env) if not (dist / "index.html").is_file() or not (dist / "index.html").stat().st_size: raise CompositionError("frontend dist requires nonempty index.html") if not (dist / "assets").is_dir() or not any(p.is_file() and p.stat().st_size for p in (dist / "assets").rglob("*")): raise CompositionError("frontend dist requires nonempty assets/") return dist, tree_digest(dist) def inject(dist, destination): if destination.is_symlink(): raise CompositionError("embed destination must not be a symlink") if destination.exists(): shutil.rmtree(destination) shutil.copytree(dist, destination) (destination / ".gitkeep").touch() def provider_revision(module, downloads): metadata = next((entry for entry in downloads if entry["Path"] == module["Path"] and entry["Version"] == module["Version"]), {}) origin = metadata.get("Origin", {}) commit = origin.get("Hash", "") pseudo = re.search(r"-([0-9a-f]{12})\Z", module["Version"]) if SHA.fullmatch(commit): if pseudo and not commit.startswith(pseudo.group(1)): raise CompositionError(f"Go Origin.Hash differs from selected version: {module['Path']}") return commit, False if pseudo: return pseudo.group(1), True raise CompositionError(f"cannot prove source commit for selected Go module: {module['Path']}@{module['Version']}") def prepare_provider_tests(root, root_commit, components, sources, manifest, temporary, fetch_env, rdm=None, rdm_admin=None, source_map_path=None): """Acquire every provider and cache before constructing the execution plan.""" graph = manifest["go"]["backend"] source_records = {name: {"path": str(path), "commit": manifest["applications"][name]["commit"], "repo": components[name]["repo"]} for name, path in sources.items()} module_sources = {} manifest["test_sources"] = {} manifest["checks"] = [] go_checks = [{"component": BACKEND, "path": sources[BACKEND], "mode": "readonly"}] npm_checks = [{"component": FRONTEND, "path": sources[FRONTEND], "commands": [["npm", "test"]]}] for name, component in components.items(): if component.get("role") != "go-library": continue selected = [(mapping, next((m for m in graph["modules"] if m["Path"] == mapping["path"]), None)) for mapping in component.get("modules", [])] selected = [(mapping, module) for mapping, module in selected if module is not None] if not selected: continue primary = next((pair for pair in selected if pair[0].get("directory", ".") == "."), selected[0]) revision, abbreviated = provider_revision(primary[1], graph["downloads"]) source = temporary / name record = export_source(component["repo"], revision, source, temporary, fetch_env, abbreviated=abbreviated) manifest["test_sources"][name] = record source_records[name] = {"path": str(source), "commit": record["commit"], "repo": component["repo"]} sources[name] = source for mapping in component.get("modules", []): module = next((m for m in graph["modules"] if m["Path"] == mapping["path"]), None) module_root = source module_commit = record["commit"] if module: module_revision, module_abbreviated = provider_revision(module, graph["downloads"]) if not module_commit.startswith(module_revision): module_root = temporary / (name + "-" + mapping["directory"].replace("/", "-")) alternate = export_source(component["repo"], module_revision, module_root, temporary, fetch_env, abbreviated=module_abbreviated) module_commit = alternate["commit"] manifest["test_sources"][mapping["path"]] = alternate location = module_root / mapping.get("directory", ".") if not (location / "go.mod").is_file(): raise CompositionError(f"declared provider module is missing: {mapping['path']}") module_sources[mapping["path"]] = {"path": str(location), "commit": module_commit, "repo": component["repo"]} manifest["go"][mapping["path"]] = go_dependencies(location, fetch_env) go_checks.append({"component": name, "module": mapping["path"], "path": location, "mode": "readonly"}) # UI is a package provider; its source identity belongs to the npm lock. ui_component = components.get("Agent-System-UI") if ui_component: package_name = ui_component.get("package_name", "@agent-system/ui") resolved = manifest["npm"]["packages"].get("node_modules/" + package_name, {}).get("resolved", "") repo, _, commit = resolved.removeprefix("git+").rpartition("#") if repo != ui_component["repo"] or not SHA.fullmatch(commit): raise CompositionError("cannot prove UI source from the Frontend npm lock") ui = temporary / "Agent-System-UI" record = export_source(repo, commit, ui, temporary, fetch_env) sources["Agent-System-UI"] = ui manifest["test_sources"]["Agent-System-UI"] = record source_records["Agent-System-UI"] = {"path": str(ui), "commit": commit, "repo": repo} for name, source in sources.items(): if name == FRONTEND or not (source / "package.json").is_file(): continue manifest.setdefault("provider_npm", {})[name] = npm_dependencies(source, components, fetch_env) scripts = json.loads((source / "package.json").read_text()).get("scripts", {}) commands = [["npm", "run", action] for action in ("typecheck", "test") if action in scripts] npm_checks.append({"component": name, "path": source, "commands": commands}) if rdm: source_records["Agent-System-RDM"] = {"path": str(rdm), **{key: manifest["applications"]["Agent-System-RDM"][key] for key in ("commit", "repo")}} go_checks.extend([ {"component": "Agent-System-RDM", "path": rdm, "mode": "vendor"}, {"component": "RDM-Backend", "path": rdm_admin, "mode": "readonly", "commit": manifest["go"]["rdm"]["backend_source"]["commit"]}, ]) formal_root = temporary / "Dev-test-root" archive = run(["git", "archive", "--format=tar", root_commit], root, fetch_env, binary=True, quiet=True) extract_archive(archive, formal_root) source_map = {"schema": 1, "root_commit": root_commit, "components": source_records, "modules": module_sources} map_file = source_map_path or temporary / "composition-sources.json" map_file.parent.mkdir(parents=True, exist_ok=True) map_file.write_text(json.dumps(source_map, indent=2) + "\n") gates = ((["bash", "scripts/lint-boundaries.sh"], "scripts/lint-boundaries.sh"), (["bash", "scripts/check-pins.sh"], "scripts/check-pins.sh"), (["make", "test-governance"], "Makefile")) project_gates = any((formal_root / required_path).is_file() for _, required_path in gates) for command, required_path in gates: if project_gates and not (formal_root / required_path).is_file(): raise CompositionError(f"fixed root is missing required test gate: {required_path}") return {"go": go_checks, "npm": npm_checks, "root": formal_root, "root_commit": root_commit, "map_file": map_file, "rdm": rdm, "gates": [command for command, required_path in gates if (formal_root / required_path).is_file()]} def run_provider_tests(prepared, manifest, clean_env): """Execute a completed plan; this phase has no acquisition environment.""" for check in prepared["npm"][:1]: for command in check["commands"]: run(command, check["path"], clean_env) manifest["checks"].append({"component": check["component"], "command": command, "result": "PASS"}) for check in prepared["go"]: for action in ("test", "vet"): command = ["go", action, "-mod=" + check["mode"], "./..."] run(command, check["path"], clean_env) identity = {key: check[key] for key in ("component", "module", "commit") if key in check} manifest["checks"].append({**identity, "command": command, "result": "PASS"}) for check in prepared["npm"][1:]: for command in check["commands"]: run(command, check["path"], clean_env) manifest["checks"].append({"component": check["component"], "command": command, "result": "PASS"}) gate_env = clean_env | {"COMPOSITION_SOURCE_MAP": str(prepared["map_file"])} if prepared["rdm"]: gate_env["COMPOSITION_RDM_SOURCE"] = str(prepared["rdm"]) for command in prepared["gates"]: run(command, prepared["root"], gate_env) manifest["checks"].append({"component": "Agent-System-Dev", "commit": prepared["root_commit"], "command": command, "result": "PASS"}) def compose(root, target, output, rdm_source=None, source_map_path=None): tooling = tooling_identity(root) transport_path = Path(__file__).resolve().parent / "workspace/transport.py" spec = importlib.util.spec_from_file_location("composition_transport", transport_path) transport = importlib.util.module_from_spec(spec) spec.loader.exec_module(transport) fetch_env = transport.environment(root) fetch_env.update(GOWORK="off", GOFLAGS="", GOTOOLCHAIN="local") root_commit, components, lock, input_hashes = inputs(root, fetch_env) if root_commit != tooling["root_commit"]: raise CompositionError("root identity changed during tooling validation") clean_env = execution_environment(fetch_env) manifest = {"schema": 1, "kind": "agent-system-composition/1", "target": target, "root": {"commit": root_commit, "inputs": input_hashes}, "tooling": tooling, "applications": {}, "go": {}, "artifacts": {}, "assets": {}} with tempfile.TemporaryDirectory(prefix="agent-system-composition-") as directory: temporary = Path(directory) sources = {} for name in (FRONTEND, BACKEND): if target == "frontend" and name == BACKEND: continue source = temporary / name manifest["applications"][name] = export_source(components[name]["repo"], lock[name]["commit"], source, temporary, fetch_env) sources[name] = source frontend = sources[FRONTEND] manifest["npm"] = npm_dependencies(frontend, components, fetch_env) products = temporary / "products" products.mkdir() rdm = None admin_source = None vendored_backend = None test_plan = None manifest["version"] = version(manifest, lock) if target != "frontend": backend = sources[BACKEND] manifest["go"]["backend"] = go_dependencies(backend, fetch_env) manifest["version"] = version(manifest, lock) if rdm_source: rdm_commit = run(["git", "rev-parse", "HEAD"], rdm_source, fetch_env, quiet=True).strip() rdm = temporary / "Agent-System-RDM" manifest["applications"]["Agent-System-RDM"] = export_source(str(rdm_source), rdm_commit, rdm, temporary, fetch_env) graph = go_dependencies(rdm, fetch_env) manifest["go"]["rdm"] = graph backend_module = next((m["path"] for m in components[BACKEND]["modules"] if m.get("directory", ".") == "."), None) selected = next((m for m in graph["modules"] if m["Path"] == backend_module), None) if not selected: raise CompositionError("RDM must consume the fixed Backend as an ordinary versioned Go module") effective = selected.get("Replace", selected) if effective.get("Path") != backend_module or not effective.get("Version"): raise CompositionError("RDM Backend must resolve to the approved versioned module") # RDM's module graph is its dependency authority. The Dev app # source lock fixes a separate application and may differ. revision, abbreviated = provider_revision(effective, graph["downloads"]) admin_source = temporary / "RDM-Backend" backend_record = export_source(components[BACKEND]["repo"], revision, admin_source, temporary, fetch_env, abbreviated=abbreviated) graph["backend_source"] = {**backend_record, "module": backend_module, "version": effective["Version"]} manifest["go"]["rdm_admin"] = go_dependencies(admin_source, fetch_env) run(["go", "mod", "vendor"], rdm, fetch_env) vendored_backend = rdm / "vendor" / backend_module if not (vendored_backend / "web/embed.go").is_file(): raise CompositionError("RDM vendored Backend does not contain the embed package") graph["vendor_modules_sha256"] = digest(rdm / "vendor/modules.txt") graph["vendor_backend_source_sha256"] = tree_digest(vendored_backend)["sha256"] if target == "test": test_plan = prepare_provider_tests(root, root_commit, components, sources, manifest, temporary, fetch_env, rdm, admin_source, source_map_path) if target == "build" and run(["git", "ls-tree", root_commit, "--", "plugins"], root, fetch_env, quiet=True).strip(): archive = run(["git", "archive", "--format=tar", root_commit, "plugins"], root, fetch_env, binary=True, quiet=True) root_assets = temporary / "root-assets" extract_archive(archive, root_assets) manifest["assets"]["plugins"] = tree_digest(root_assets / "plugins") manifest["assets"]["plugins"]["root_commit"] = root_commit # All fixed sources, package caches, vendor files and root assets are # acquired above. Candidate commands below receive only clean_env; # there is no acquisition environment in the execution plan. del fetch_env dist, manifest["frontend"] = frontend_assets(frontend, clean_env) manifest["toolchains"] = {"go": run(["go", "version"], temporary, clean_env, quiet=True).strip(), "node": run(["node", "--version"], temporary, clean_env, quiet=True).strip(), "npm": run(["npm", "--version"], temporary, clean_env, quiet=True).strip()} if target != "frontend": inject(dist, backend / "web/dist") manifest["go"]["backend"]["embed_sha256"] = tree_digest(backend / "web/dist")["sha256"] if rdm: graph = manifest["go"]["rdm"] inject(dist, admin_source / "web/dist") manifest["go"]["rdm_admin"]["embed_sha256"] = tree_digest(admin_source / "web/dist")["sha256"] graph["admin_version"] = version(manifest, lock, backend_commit=graph["backend_source"]["commit"], resolved_go={"rdm": graph["modules"], "admin": manifest["go"]["rdm_admin"]}) inject(dist, vendored_backend / "web/dist") graph["vendor_backend_dist_sha256"] = tree_digest(vendored_backend / "web/dist")["sha256"] if target == "test": run_provider_tests(test_plan, manifest, clean_env) else: for name, package in (("agent-system", "./cmd/agent-system"), ("storage-migrate", "./cmd/storage-migrate")): flags = "-s -w" + (" -X main.version=" + manifest["version"] if name == "agent-system" else "") run(["go", "build", "-mod=readonly", "-trimpath", "-buildvcs=false", "-ldflags=" + flags, "-o", str(products / name), package], backend, clean_env) if rdm: run(["go", "build", "-mod=vendor", "-trimpath", "-buildvcs=false", "-ldflags=-s -w", "-o", str(products / "agent-system-rdm-product"), "."], rdm, clean_env) run(["go", "build", "-mod=readonly", "-trimpath", "-buildvcs=false", "-ldflags=-s -w -X main.version=" + graph["admin_version"], "-o", str(products / "agent-system-admin"), "./cmd/agent-system"], admin_source, clean_env) for artifact in products.iterdir(): manifest["artifacts"][artifact.name] = {"sha256": digest(artifact), "size": artifact.stat().st_size} if tooling_identity(root, clean_env) != tooling: raise CompositionError("executing tooling changed during composition") # Publish only after all required build/test commands succeeded. output.mkdir(parents=True, exist_ok=True) for artifact in products.iterdir(): shutil.copy2(artifact, output / artifact.name) if "plugins" in manifest["assets"]: destination = output / "plugins" if destination.exists(): shutil.rmtree(destination) shutil.copytree(root_assets / "plugins", destination) if target == "frontend": destination = output / "frontend-dist" if destination.exists(): shutil.rmtree(destination) shutil.copytree(dist, destination) pending = output / ".resolved-manifest.json.tmp" pending.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") pending.replace(output / "resolved-manifest.json") print(f"[composition] complete: {target}; manifest={output / 'resolved-manifest.json'}", flush=True) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) parser.add_argument("--target", required=True, choices=("frontend", "build", "test")) parser.add_argument("--output", type=Path) parser.add_argument("--rdm-source", type=Path) parser.add_argument("--source-map", type=Path, help="Write source identity map used by test gates; paths exist only during this run") args = parser.parse_args() root = args.root.resolve() try: compose(root, args.target, (args.output or root / "bin").resolve(), args.rdm_source.resolve() if args.rdm_source else None, args.source_map.resolve() if args.source_map else None) except (CompositionError, OSError, ValueError, KeyError, TypeError) as error: print(f"composition: {error}", file=sys.stderr) return 2 return 0 if __name__ == "__main__": sys.exit(main())