"""Create a clean, shallow Git checkout ZIP that can fast-forward on launch.""" import argparse from datetime import datetime import hashlib import os from pathlib import Path import subprocess import tempfile from urllib.parse import urlsplit import zipfile EXCLUDED_DIRS = { ".git", ".venv", "venv", "env", "node_modules", "__pycache__", "__pypackages__", ".angular", ".gradle", ".pytest_cache", ".ruff_cache", ".mypy_cache", ".cache", ".tox", ".nox", ".hypothesis", ".eggs", ".idea", ".vscode", ".vs", ".codex", ".claude", ".gemini", ".cursor", ".roo", ".ipynb_checkpoints", "build", "dist", "out", "coverage", "htmlcov", "artifacts", "traces", "scratch", "outputs", "trace_output", } EXCLUDED_SUFFIXES = { ".pyc", ".pyo", ".pyd", ".so", ".dll", ".exe", ".o", ".obj", ".a", ".lib", ".class", ".jar", ".apk", ".aab", ".wasm", ".map", ".tsbuildinfo", ".egg", ".whl", ".zip", ".7z", ".tar", ".gz", ".log", ".db", ".sqlite", ".sqlite3", ".pem", ".key", ".keystore", ".p12", ".pfx", ".swp", ".swo", } GIT_METADATA_FILES = {"HEAD", "config", "index", "shallow", "packed-refs"} GIT_METADATA_DIRS = {"objects", "refs"} RUNTIME_PAYLOADS = {"packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk"} def excluded(path: Path) -> bool: parts = tuple(part.lower() for part in path.parts) name = parts[-1] if any(part in EXCLUDED_DIRS or part.endswith(".egg-info") for part in parts): return True # This tracked directory also contains generated Angular bundles. if parts[:3] == ("artemis", "resources", "showcase_ui"): return True if (name == ".env" or name.startswith(".env.")) and name != ".env.example": return True if name.startswith((".artemis_", ".jetski_", ".coverage")): return True if name in {".ds_store", "thumbs.db", ".ipc_port", ".ls_address", ".npmrc", ".pypirc"}: return True # The tracked helper is needed at runtime on fresh Android installations. # Keep this exact payload, while excluding unrelated APKs and build outputs. if path.as_posix() in RUNTIME_PAYLOADS: return False return path.suffix.lower() in EXCLUDED_SUFFIXES or name.endswith((".db-wal", ".db-shm", "~")) def source_files(root: Path): for directory, dirs, files in os.walk(root, followlinks=False): current = Path(directory) dirs[:] = sorted( name for name in dirs if not (current / name).is_symlink() and not excluded((current / name).relative_to(root)) ) for name in sorted(files): path = current / name if not path.is_symlink() and not excluded(path.relative_to(root)): yield path def git(root: Path, *args: str) -> str: result = subprocess.run( ["git", "-C", str(root), *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=90, ) if result.returncode: raise RuntimeError(f"git {args[0]} failed (exit {result.returncode})") return result.stdout.strip() def git_metadata_files(root: Path): metadata = root / ".git" for name in sorted(GIT_METADATA_FILES): path = metadata / name if path.is_file(): yield path for name in sorted(GIT_METADATA_DIRS): directory = metadata / name if directory.exists(): yield from source_files_without_filter(directory) for name in ("exclude", "sparse-checkout"): path = metadata / "info" / name if path.is_file(): yield path def source_files_without_filter(root: Path): for directory, dirs, files in os.walk(root, followlinks=False): current = Path(directory) dirs[:] = sorted(name for name in dirs if not (current / name).is_symlink()) for name in sorted(files): path = current / name if not path.is_symlink(): yield path def release_remote(root: Path) -> tuple[str, str]: if not (root / ".git").exists(): raise ValueError("Source packaging requires a Git checkout") if git(root, "status", "--porcelain", "--untracked-files=no"): raise ValueError("Commit tracked changes before packaging a release") branch = git(root, "symbolic-ref", "--quiet", "--short", "HEAD") remote = git(root, "config", "--get", f"branch.{branch}.remote") merge_ref = git(root, "config", "--get", f"branch.{branch}.merge") if remote == "." or merge_ref != f"refs/heads/{branch}": raise ValueError("The current branch must track the same branch on a remote") url = git(root, "remote", "get-url", remote) parsed = urlsplit(url) if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password: raise ValueError("The release upstream must be a credential-free HTTPS URL") if parsed.query or parsed.fragment: raise ValueError("The release upstream must not contain a query or fragment") return branch, url def package_source(root: Path, output: Path) -> tuple[int, str]: root = root.resolve() output = output.resolve() branch, remote_url = release_remote(root) revision = git(root, "rev-parse", "HEAD") output.parent.mkdir(parents=True, exist_ok=True) temporary = output.with_suffix(".zip.tmp") try: with tempfile.TemporaryDirectory(prefix="artemis-release-") as work: checkout = Path(work) / "artemis" git( root, "clone", "--depth", "1", "--single-branch", "--branch", branch, root.as_uri(), str(checkout), ) git(checkout, "remote", "set-url", "origin", remote_url) # Generated assets are tracked upstream but must not appear in the # unpacked source. Sparse checkout keeps the Git index clean. omitted = [ name for name in git(checkout, "ls-files").splitlines() if excluded(Path(name)) ] patterns = ["/*", *(f"!/{name}" for name in omitted)] git(checkout, "sparse-checkout", "set", "--no-cone", *patterns) # Rebuilt assets in sparse paths must not look like user edits. info_exclude = checkout / ".git" / "info" / "exclude" with info_exclude.open("a", encoding="utf-8") as stream: stream.write("\n".join(["", *(f"/{name}" for name in omitted), ""])) if git(checkout, "rev-parse", "HEAD") != revision or git( checkout, "status", "--porcelain" ): raise RuntimeError("The packaged checkout is not clean at the requested revision") files = [*source_files(checkout), *git_metadata_files(checkout)] names = { str(Path("artemis") / path.relative_to(checkout)).replace("\\", "/") for path in files } with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive: for path in files: archive.write(path, Path("artemis") / path.relative_to(checkout)) with zipfile.ZipFile(temporary) as archive: bad_file = archive.testzip() if bad_file or set(archive.namelist()) != names: raise RuntimeError( f"ZIP integrity check failed: {bad_file or 'file list mismatch'}" ) temporary.replace(output) finally: temporary.unlink(missing_ok=True) with output.open("rb") as stream: digest = hashlib.file_digest(stream, "sha256").hexdigest() return len(files), digest if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) parser.add_argument("--output", type=Path) args = parser.parse_args() output = ( args.output or args.root / "artifacts" / f"artemis-source-{datetime.now():%Y%m%d-%H%M%S}.zip" ) count, digest = package_source(args.root, output) print(f"Archive: {output.resolve()}") print(f"Files: {count}; size: {output.stat().st_size / (1024 * 1024):.2f} MiB") print(f"SHA256: {digest}")