"""scripts/pack_release.py — bundle code into a release zip and push to S3. Defines the set of files that constitute a "release" as everything in the git index (i.e. everything ``git ls-files`` would list). Banks, images, secrets, runtime DB, and other artifacts that don't live in git are excluded automatically. The zip is named ``release--.zip`` and uploaded to ``s3:///releases/``. The pointer object ``releases/latest.zip`` is updated via server-side copy so the server's deploy script always knows what 'current' means. Config (.env or env vars): S3_BUCKET, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY S3_RELEASES_PREFIX default 'releases/' Usage: python scripts/pack_release.py # require clean tree python scripts/pack_release.py --allow-dirty # pack with uncommitted changes python scripts/pack_release.py --dry-run # show file list, no upload python scripts/pack_release.py --no-upload # build zip locally only """ from __future__ import annotations import argparse import os import subprocess import sys import zipfile from datetime import date from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) try: from dotenv import load_dotenv load_dotenv(ROOT / ".env") except ImportError: pass BUCKET = os.environ.get("S3_BUCKET") REGION = os.environ.get("AWS_REGION") PREFIX = os.environ.get("S3_RELEASES_PREFIX", "releases/") def _git(args: list[str]) -> str: res = subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True) if res.returncode != 0: raise RuntimeError(f"git {' '.join(args)} failed: {res.stderr.strip()}") return res.stdout def _check_clean_tree() -> None: """Fail-fast if there are uncommitted changes.""" status = _git(["status", "--porcelain"]).strip() if status: print("ERROR: working tree is dirty. Commit your changes or pass --allow-dirty.", file=sys.stderr) print("Modified files:", file=sys.stderr) for line in status.splitlines(): print(f" {line}", file=sys.stderr) raise SystemExit(1) def _current_commit_short() -> str: return _git(["rev-parse", "--short", "HEAD"]).strip() def _tracked_files() -> list[Path]: """Files in the git index — the source of truth for 'what's in a release'.""" out = _git(["ls-files"]).strip() return [ROOT / line for line in out.splitlines() if line] def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--allow-dirty", action="store_true", help="Pack with uncommitted changes (NOT recommended for prod).") parser.add_argument("--dry-run", action="store_true", help="Show what would be packed; don't write or upload.") parser.add_argument("--no-upload", action="store_true", help="Build the zip locally; don't push to S3.") args = parser.parse_args() if not args.allow_dirty and not args.dry_run: _check_clean_tree() commit = _current_commit_short() name = f"release-{date.today().isoformat()}-{commit}.zip" zip_path = ROOT / name print(f"Commit: {commit}") print(f"Output: {zip_path.name}") print() files = _tracked_files() # Only files that actually exist on disk (skip rows for deleted-but-not-staged etc.) files = [f for f in files if f.exists()] total_bytes = sum(f.stat().st_size for f in files if f.is_file()) print(f"Tracked files: {len(files)}, total {total_bytes / 1024:.1f} KB") if args.dry_run: # Just list a sample plus the count. for f in files[:20]: print(f" {f.relative_to(ROOT)}") if len(files) > 20: print(f" ... and {len(files) - 20} more") print() print("Dry-run only. No zip created.") return 0 # Build the zip. with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf: for f in files: if not f.is_file(): continue zf.write(f, f.relative_to(ROOT).as_posix()) print(f"Wrote {zip_path.name} ({zip_path.stat().st_size / 1024:.1f} KB)") if args.no_upload: print() print("--no-upload set; skipping S3 push. Zip remains at:") print(f" {zip_path}") return 0 if not BUCKET or not REGION: print("ERROR: S3_BUCKET and AWS_REGION required for upload.", file=sys.stderr) return 1 import boto3 from botocore.config import Config from botocore.exceptions import ClientError s3 = boto3.client( "s3", region_name=REGION, aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"], aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"], endpoint_url=f"https://s3.{REGION}.amazonaws.com", config=Config(signature_version="s3v4", s3={"addressing_style": "virtual"}), ) key = f"{PREFIX}{name}" try: s3.upload_file(str(zip_path), BUCKET, key, ExtraArgs={"ContentType": "application/zip"}) print(f"Uploaded -> s3://{BUCKET}/{key}") latest_key = f"{PREFIX}latest.zip" s3.copy_object( Bucket=BUCKET, Key=latest_key, CopySource={"Bucket": BUCKET, "Key": key}, MetadataDirective="REPLACE", ContentType="application/zip", ) print(f"Updated -> s3://{BUCKET}/{latest_key} (now points at {commit})") except ClientError as e: print(f"ERROR uploading to S3: {e}", file=sys.stderr) return 1 finally: # Clean up local zip — the canonical copy lives in S3. try: zip_path.unlink() except Exception: pass print() print("Release published.") return 0 if __name__ == "__main__": raise SystemExit(main())