Package a clean shallow main checkout with credential-free Gitea remote, localize the China launcher, and set the new-install Responses endpoint.
256 lines
8.1 KiB
Python
256 lines
8.1 KiB
Python
"""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"}
|
|
|
|
|
|
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
|
|
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}")
|