mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-19 10:04:16 +00:00
400 lines
14 KiB
Python
400 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate the embedded managed-repo bundle for packaged builds."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
|
|
MANIFEST_SCHEMA_VERSION = 1
|
|
DEFAULT_MANAGED_REMOTE_NAME = "managed"
|
|
DEFAULT_LOCAL_BRANCH = "ouroboros"
|
|
DEFAULT_LOCAL_STABLE_BRANCH = "ouroboros-stable"
|
|
DEFAULT_REMOTE_STABLE_BRANCH = "ouroboros-stable"
|
|
_PRE_SUFFIX = r'(?:-?(?:rc|alpha|beta|a|b)\.?\d+)?'
|
|
_VERSION_RE = re.compile(r'^\d+\.\d+\.\d+' + _PRE_SUFFIX + r'$', re.IGNORECASE)
|
|
|
|
|
|
def _run(cmd: list[str], *, cwd: pathlib.Path, capture_output: bool = False) -> subprocess.CompletedProcess:
|
|
return subprocess.run(
|
|
cmd,
|
|
cwd=str(cwd),
|
|
check=True,
|
|
capture_output=capture_output,
|
|
text=True,
|
|
)
|
|
|
|
|
|
def _git_output(repo_root: pathlib.Path, *args: str) -> str:
|
|
result = _run(["git", *args], cwd=repo_root, capture_output=True)
|
|
return (result.stdout or "").strip()
|
|
|
|
|
|
def _read_version(repo_root: pathlib.Path) -> str:
|
|
version_path = repo_root / "VERSION"
|
|
if not version_path.is_file():
|
|
raise SystemExit("VERSION file is missing; cannot build repo bundle.")
|
|
version = version_path.read_text(encoding="utf-8").strip()
|
|
if not _VERSION_RE.match(version):
|
|
raise SystemExit(f"VERSION {version!r} is not a supported release version.")
|
|
return version
|
|
|
|
|
|
def _current_branch(repo_root: pathlib.Path) -> str:
|
|
branch = _git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
if not branch or branch == "HEAD":
|
|
raise SystemExit(
|
|
"Detached HEAD is not supported for repo bundle generation. "
|
|
"Pass --source-branch explicitly from a named branch checkout."
|
|
)
|
|
return branch
|
|
|
|
|
|
def _ensure_clean_worktree(repo_root: pathlib.Path) -> None:
|
|
status = _git_output(repo_root, "status", "--porcelain")
|
|
if status:
|
|
raise SystemExit(
|
|
"Refusing to build repo.bundle from a dirty working tree. "
|
|
"Commit or stash changes first so the embedded managed repo matches the packaged code."
|
|
)
|
|
|
|
|
|
def _validate_source_branch(repo_root: pathlib.Path, source_branch: str) -> None:
|
|
"""Verify the managed source branch is usable for provenance checks.
|
|
|
|
Only accepts branches that resolve locally — either as a real local
|
|
branch (``refs/heads/<branch>``), a cached origin ref
|
|
(``refs/remotes/origin/<branch>``), or any other locally-known ref.
|
|
Remote-visible-only branches (discoverable via ``git ls-remote`` but
|
|
not fetched) are refused here instead of silently passing and then
|
|
failing later inside ``_ensure_source_sha_tracks_branch``.
|
|
"""
|
|
branch = str(source_branch or "").strip()
|
|
if not branch:
|
|
raise SystemExit("managed source branch must not be empty.")
|
|
|
|
for ref in (f"refs/heads/{branch}", f"refs/remotes/origin/{branch}", branch):
|
|
try:
|
|
_git_output(repo_root, "rev-parse", "--verify", ref)
|
|
return
|
|
except subprocess.CalledProcessError:
|
|
continue
|
|
|
|
raise SystemExit(
|
|
f"Configured managed source branch {branch!r} is not available locally. "
|
|
"Fetch it first (e.g. `git fetch origin {branch}`) before building the repo bundle."
|
|
.replace("{branch}", branch)
|
|
)
|
|
|
|
|
|
def _origin_url(repo_root: pathlib.Path) -> str:
|
|
try:
|
|
return _git_output(repo_root, "config", "--get", "remote.origin.url")
|
|
except subprocess.CalledProcessError:
|
|
return ""
|
|
|
|
|
|
def _normalize_remote_url(raw_url: str) -> str:
|
|
url = str(raw_url or "").strip()
|
|
if not url:
|
|
return ""
|
|
|
|
github_ssh = re.match(r"^git@github\.com:(.+)$", url)
|
|
if github_ssh:
|
|
return f"https://github.com/{github_ssh.group(1)}"
|
|
|
|
github_ssh_url = re.match(r"^ssh://git@github\.com/(.+)$", url)
|
|
if github_ssh_url:
|
|
return f"https://github.com/{github_ssh_url.group(1)}"
|
|
|
|
parts = urlsplit(url)
|
|
if parts.scheme in {"http", "https"} and parts.netloc:
|
|
host = (parts.hostname or "").strip()
|
|
if host:
|
|
if host.lower() == "github.com":
|
|
return urlunsplit(("https", "github.com", parts.path, "", ""))
|
|
netloc = parts.netloc.split("@", 1)[-1]
|
|
return urlunsplit((parts.scheme, netloc, parts.path, "", ""))
|
|
return url
|
|
|
|
|
|
def _sha256_file(path: pathlib.Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as fh:
|
|
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _normalize_release_tag(tag: str) -> str:
|
|
raw = str(tag or "").strip()
|
|
if not raw:
|
|
return ""
|
|
version = raw[1:] if raw.lower().startswith("v") else raw
|
|
if not _VERSION_RE.match(version):
|
|
return ""
|
|
return f"v{version}"
|
|
|
|
|
|
def _verify_release_tag_in_repo(repo_root: pathlib.Path, tag: str) -> None:
|
|
"""Fail-closed checks for a resolved release tag.
|
|
|
|
Three invariants must hold before the tag is embedded into
|
|
``repo_bundle_manifest.json``:
|
|
|
|
1. ``refs/tags/<tag>`` actually exists in the repo (i.e. the tag was
|
|
created, not just passed via an env var).
|
|
2. The tag object is *annotated* (``git cat-file -t`` returns
|
|
``"tag"``, not ``"commit"``) — BIBLE.md P9 requires an annotated
|
|
tag for every release, so a lightweight tag is not acceptable.
|
|
3. The tag points at ``HEAD``. A tag that exists but points at a
|
|
previous commit would make the packaged manifest lie about which
|
|
commit it represents.
|
|
|
|
Each failure raises ``SystemExit`` with a concrete, actionable
|
|
message — this function is called only from the packaging path, so
|
|
aborting is the intended recovery.
|
|
"""
|
|
probe = subprocess.run(
|
|
["git", "rev-parse", "--verify", f"refs/tags/{tag}"],
|
|
cwd=str(repo_root),
|
|
check=False,
|
|
capture_output=True,
|
|
)
|
|
if probe.returncode != 0:
|
|
raise SystemExit(
|
|
f"Release tag {tag} is not present in the repository. "
|
|
f"Create it first: git tag -a {tag} -m \"Release {tag}\"."
|
|
)
|
|
tag_type_probe = subprocess.run(
|
|
["git", "cat-file", "-t", f"refs/tags/{tag}"],
|
|
cwd=str(repo_root),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
tag_type = (tag_type_probe.stdout or "").strip()
|
|
if tag_type_probe.returncode != 0 or tag_type != "tag":
|
|
raise SystemExit(
|
|
f"Release tag {tag} is not an annotated tag "
|
|
f"(git cat-file -t returned {tag_type!r}). "
|
|
f"BIBLE.md P9 requires annotated release tags — recreate with "
|
|
f"`git tag -a {tag} -m \"Release {tag}\"`."
|
|
)
|
|
head_sha_probe = subprocess.run(
|
|
["git", "rev-parse", "HEAD"],
|
|
cwd=str(repo_root),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
tag_sha_probe = subprocess.run(
|
|
["git", "rev-list", "-1", f"refs/tags/{tag}"],
|
|
cwd=str(repo_root),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if head_sha_probe.returncode != 0 or tag_sha_probe.returncode != 0:
|
|
raise SystemExit(
|
|
f"Could not resolve SHA for HEAD or {tag}: "
|
|
f"head_rc={head_sha_probe.returncode}, tag_rc={tag_sha_probe.returncode}."
|
|
)
|
|
head_sha = (head_sha_probe.stdout or "").strip()
|
|
tag_sha = (tag_sha_probe.stdout or "").strip()
|
|
if not head_sha or not tag_sha or head_sha != tag_sha:
|
|
raise SystemExit(
|
|
f"Release tag {tag} does not point at HEAD "
|
|
f"(tag={tag_sha or '<unknown>'}, HEAD={head_sha or '<unknown>'}). "
|
|
f"Re-tag the current commit before packaging."
|
|
)
|
|
|
|
|
|
def _resolve_release_tag(repo_root: pathlib.Path, version: str) -> str:
|
|
candidates = []
|
|
env_candidates = [os.environ.get("OUROBOROS_RELEASE_TAG", "")]
|
|
github_ref_type = str(os.environ.get("GITHUB_REF_TYPE", "") or "").strip().lower()
|
|
github_ref = str(os.environ.get("GITHUB_REF", "") or "").strip()
|
|
if github_ref_type == "tag" or github_ref.startswith("refs/tags/"):
|
|
env_candidates.append(os.environ.get("GITHUB_REF_NAME", ""))
|
|
for raw in env_candidates:
|
|
tag = _normalize_release_tag(raw)
|
|
if raw and not tag:
|
|
raise SystemExit(f"Release tag {raw!r} is not a supported release tag.")
|
|
if tag:
|
|
candidates.append(tag)
|
|
if not candidates:
|
|
tags = [line.strip() for line in _git_output(repo_root, "tag", "--points-at", "HEAD").splitlines() if line.strip()]
|
|
candidates = [tag for tag in (_normalize_release_tag(item) for item in tags) if tag]
|
|
unique = sorted(set(candidates))
|
|
if len(unique) > 1:
|
|
raise SystemExit(f"Multiple release tags point at HEAD: {', '.join(unique)}")
|
|
release_tag = unique[0] if unique else ""
|
|
expected_tag = f"v{version}"
|
|
if not release_tag:
|
|
raise SystemExit(
|
|
"Could not determine release tag for repo.bundle. "
|
|
f"Tag HEAD with {expected_tag} or set OUROBOROS_RELEASE_TAG."
|
|
)
|
|
if release_tag and release_tag != expected_tag:
|
|
raise SystemExit(
|
|
f"Release tag {release_tag} does not match VERSION {version} "
|
|
f"(expected {expected_tag})."
|
|
)
|
|
_verify_release_tag_in_repo(repo_root, release_tag)
|
|
return release_tag
|
|
|
|
|
|
def _ensure_source_sha_tracks_branch(repo_root: pathlib.Path, source_sha: str, source_branch: str) -> None:
|
|
candidate_refs = []
|
|
for ref in (f"refs/heads/{source_branch}", f"refs/remotes/origin/{source_branch}"):
|
|
probe = subprocess.run(
|
|
["git", "rev-parse", "--verify", ref],
|
|
cwd=str(repo_root),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if probe.returncode == 0:
|
|
candidate_refs.append(ref)
|
|
if not candidate_refs:
|
|
raise SystemExit(
|
|
f"Managed source branch {source_branch!r} must be available locally "
|
|
"for provenance checks before building repo.bundle."
|
|
)
|
|
for ref in candidate_refs:
|
|
probe = subprocess.run(
|
|
["git", "merge-base", "--is-ancestor", source_sha, ref],
|
|
cwd=str(repo_root),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if probe.returncode == 0:
|
|
return
|
|
raise SystemExit(
|
|
f"HEAD {source_sha[:12]} is not reachable from the configured managed "
|
|
f"source branch {source_branch!r}."
|
|
)
|
|
|
|
|
|
def build_bundle(
|
|
repo_root: pathlib.Path,
|
|
output_bundle: pathlib.Path,
|
|
output_manifest: pathlib.Path,
|
|
*,
|
|
source_branch: str,
|
|
local_branch: str,
|
|
local_stable_branch: str,
|
|
remote_stable_branch: str,
|
|
managed_remote_name: str,
|
|
) -> None:
|
|
_ensure_clean_worktree(repo_root)
|
|
_validate_source_branch(repo_root, source_branch)
|
|
source_sha = _git_output(repo_root, "rev-parse", "HEAD")
|
|
version = _read_version(repo_root)
|
|
release_tag = _resolve_release_tag(repo_root, version)
|
|
_ensure_source_sha_tracks_branch(repo_root, source_sha, source_branch)
|
|
remote_url = _normalize_remote_url(_origin_url(repo_root))
|
|
|
|
output_bundle.parent.mkdir(parents=True, exist_ok=True)
|
|
output_manifest.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if output_bundle.exists():
|
|
output_bundle.unlink()
|
|
if output_manifest.exists():
|
|
output_manifest.unlink()
|
|
|
|
_run(
|
|
["git", "bundle", "create", str(output_bundle), "HEAD", "--tags"],
|
|
cwd=repo_root,
|
|
)
|
|
bundle_sha256 = _sha256_file(output_bundle)
|
|
|
|
manifest = {
|
|
"schema_version": MANIFEST_SCHEMA_VERSION,
|
|
"bundle_file": output_bundle.name,
|
|
"app_version": version,
|
|
"source_sha": source_sha,
|
|
"release_tag": release_tag,
|
|
"bundle_sha256": bundle_sha256,
|
|
"source_branch": source_branch,
|
|
"managed_remote_name": managed_remote_name,
|
|
"managed_remote_url": remote_url,
|
|
"managed_remote_branch": source_branch,
|
|
"managed_local_branch": local_branch,
|
|
"managed_local_stable_branch": local_stable_branch,
|
|
"managed_remote_stable_branch": remote_stable_branch,
|
|
}
|
|
output_manifest.write_text(json.dumps(manifest, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--repo-root", default=".", help="Repository root containing .git and VERSION.")
|
|
parser.add_argument("--output-bundle", default="repo.bundle", help="Output git bundle path.")
|
|
parser.add_argument(
|
|
"--output-manifest",
|
|
default="repo_bundle_manifest.json",
|
|
help="Output bootstrap manifest path.",
|
|
)
|
|
parser.add_argument(
|
|
"--source-branch",
|
|
default="",
|
|
help="Remote source branch to embed. Defaults to the current checked-out branch.",
|
|
)
|
|
parser.add_argument(
|
|
"--managed-local-branch",
|
|
default=DEFAULT_LOCAL_BRANCH,
|
|
help="Local branch name used inside launcher-managed installs.",
|
|
)
|
|
parser.add_argument(
|
|
"--managed-local-stable-branch",
|
|
default=DEFAULT_LOCAL_STABLE_BRANCH,
|
|
help="Local fallback branch name used inside launcher-managed installs.",
|
|
)
|
|
parser.add_argument(
|
|
"--managed-remote-stable-branch",
|
|
default=DEFAULT_REMOTE_STABLE_BRANCH,
|
|
help="Remote fallback branch name for launcher-managed installs.",
|
|
)
|
|
parser.add_argument(
|
|
"--managed-remote-name",
|
|
default=DEFAULT_MANAGED_REMOTE_NAME,
|
|
help="Dedicated git remote name used by launcher-managed installs.",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
repo_root = pathlib.Path(args.repo_root).resolve()
|
|
if not (repo_root / ".git").exists():
|
|
raise SystemExit(f"{repo_root} is not a git repository.")
|
|
if str(args.managed_remote_name or "").strip() == "origin":
|
|
raise SystemExit("managed remote name 'origin' is reserved for user-configured remotes.")
|
|
|
|
source_branch = args.source_branch or _current_branch(repo_root)
|
|
output_bundle = (repo_root / args.output_bundle).resolve()
|
|
output_manifest = (repo_root / args.output_manifest).resolve()
|
|
build_bundle(
|
|
repo_root,
|
|
output_bundle,
|
|
output_manifest,
|
|
source_branch=source_branch,
|
|
local_branch=args.managed_local_branch,
|
|
local_stable_branch=args.managed_local_stable_branch,
|
|
remote_stable_branch=args.managed_remote_stable_branch,
|
|
managed_remote_name=args.managed_remote_name,
|
|
)
|
|
print(f"Generated {output_bundle.name} and {output_manifest.name} from {source_branch}.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|