"""Build the Zenodo archive (open release, Apache-2.0): the repository at HEAD of release (`git archive`), its full history (`git bundle`), the simbracket wheels, README, requirements, the reproduction script, LICENSE, NOTICE and SHA256SUMS - zipped as sim-numerics-.zip. Refuses to build if the tree or the bundled history contains the server address or the specifics of the next method, if HEAD is not release, or if a text file of the archive has Windows line ends. python -u zenodo/make_open_package.py 1.0.0 """ from __future__ import annotations import hashlib import os import shutil import subprocess import sys import zipfile ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) VERSION = sys.argv[1] if len(sys.argv) > 1 else "1.0.0" NAME = f"sim-numerics-{VERSION}" OUT = os.path.join(ROOT, "zenodo_build") PKG = os.path.join(OUT, NAME) # The strings that must appear in no commit are read from a file OUTSIDE the repository (named by # $ZENODO_FORBIDDEN): listing them here would put them into the history this check protects. FORBIDDEN_FILE = os.environ.get("ZENODO_FORBIDDEN", "") WHEELS = ["lib/dist/simbracket-1.0.0-cp310-abi3-linux_x86_64.whl", "lib/dist/simbracket-1.0.0-cp310-abi3-win_amd64.whl"] CRLF = bytes([13, 10]) def git(*args, **kw): return subprocess.run(["git", "-C", ROOT, *args], check=True, capture_output=True, **kw) def main(): branch = git("branch", "--show-current", text=True).stdout.strip() assert branch == "release", branch assert not git("status", "--porcelain", "--untracked-files=no", text=True).stdout.strip(), \ "commit everything first: the archive is HEAD" assert FORBIDDEN_FILE and os.path.isfile(FORBIDDEN_FILE), "set ZENODO_FORBIDDEN to the private list" assert not os.path.abspath(FORBIDDEN_FILE).startswith(ROOT), "the list must live outside the repository" forbidden = [ln.strip() for ln in open(FORBIDDEN_FILE, encoding="utf8") if ln.strip()] for s in forbidden: # every commit of the branch hits = subprocess.run(["git", "-C", ROOT, "log", "release", "-p", "-S", s, "--format=%h"], capture_output=True, text=True, encoding="utf8").stdout.split() assert not hits, (s, hits[:3]) if os.path.isdir(OUT): shutil.rmtree(OUT) os.makedirs(os.path.join(PKG, "wheels")) tar = os.path.join(OUT, "source.tar") # core.autocrlf=false: the archive carries the repository's LF line ends, not Windows' CRLF git("-c", "core.autocrlf=false", "archive", "--format=tar", "--prefix=source/", "-o", tar, "HEAD") shutil.unpack_archive(tar, PKG) os.remove(tar) # HEAD too, so that `git clone sim-numerics.bundle` checks the branch out git("bundle", "create", os.path.join(PKG, "sim-numerics.bundle"), "HEAD", "release") for w in WHEELS: shutil.copy(os.path.join(ROOT, w), os.path.join(PKG, "wheels")) src = os.path.join(PKG, "source") # from the archive: LF, exactly as committed for f in ("README.md", "requirements.txt", "reproduce.sh"): shutil.copy(os.path.join(src, "zenodo", f), os.path.join(PKG, f)) for f in ("LICENSE", "NOTICE"): shutil.copy(os.path.join(src, f), os.path.join(PKG, f)) bad = [] for d, _, files in os.walk(PKG): for f in files: if f.endswith((".py", ".sh", ".md", ".txt")) or f in ("LICENSE", "NOTICE"): if CRLF in open(os.path.join(d, f), "rb").read(): bad.append(os.path.relpath(os.path.join(d, f), PKG)) assert not bad, ("Windows line ends", bad[:5]) sums = [] for d, _, files in os.walk(PKG): for f in sorted(files): p = os.path.join(d, f) rel = os.path.relpath(p, PKG).replace("\\", "/") sums.append(f"{hashlib.sha256(open(p, 'rb').read()).hexdigest()} {rel}") with open(os.path.join(PKG, "SHA256SUMS"), "w", encoding="ascii", newline="\n") as fh: fh.write("\n".join(sorted(sums, key=lambda s: s[66:])) + "\n") zp = os.path.join(OUT, NAME + ".zip") with zipfile.ZipFile(zp, "w", zipfile.ZIP_DEFLATED) as z: for d, _, files in os.walk(PKG): for f in files: p = os.path.join(d, f) info = zipfile.ZipInfo.from_file(p, os.path.relpath(p, OUT).replace("\\", "/")) if f.endswith(".sh"): info.external_attr = 0o755 << 16 with open(p, "rb") as src_fh: z.writestr(info, src_fh.read(), zipfile.ZIP_DEFLATED) head = git("rev-parse", "--short", "HEAD", text=True).stdout.strip() print(f"{zp}: {os.path.getsize(zp) / 1e6:.1f} MB, {len(sums)} files, source at {head}") print("zenodo package done") if __name__ == "__main__": main()