#!/usr/bin/env python3 """scripts/attach_release_asset.py — upload one or more files as Gitea release attachments. REQ-228 (v1.18): PPTX (and any deck artifact) is attached to the phase's Gitea release. Uses the Gitea API: POST /api/v1/repos/{owner}/{repo}/releases/{id}/assets multipart form: name=, attachment= REQ-270 (v1.23): supports dual PPTX attachment — the MARP PPTX (primary, attached first) and the python-pptx PPTX (comparison artifact). Multiple file paths are accepted; the first is the primary attachment. Usage: python3 scripts/attach_release_asset.py python3 scripts/attach_release_asset.py ... python3 scripts/attach_release_asset.py docs/presentations/nova-autonomous-cloud-delivery.pptx 522 python3 scripts/attach_release_asset.py \ docs/presentations/nova-autonomous-cloud-delivery.pptx \ docs/presentations/nova-autonomous-cloud-delivery-python.pptx 522 The last positional argument is always the release id; every preceding argument is an asset path (backward compatible with the single-asset call). Token resolution: reads NOVA_GITEA_TOKEN (or ACDL_GITEA_TOKEN) from .env.secrets / .env, matching the ship_phase.sh pattern. Never uses shell env tokens. """ import os import sys import json import urllib.request import urllib.error from pathlib import Path GITEA_BASE = "https://git.cloudinit.dev" OWNER = "continuous-intelligence" REPO = "acdl" def resolve_token() -> str: for fn in (".env.secrets", ".env"): try: for line in Path(fn).read_text().splitlines(): if line.startswith("NOVA_GITEA_TOKEN=") or line.startswith("ACDL_GITEA_TOKEN="): return line.split("=", 1)[1].strip() except (FileNotFoundError, PermissionError): continue raise RuntimeError("No Gitea token found in .env.secrets or .env (NOVA_GITEA_TOKEN/ACDL_GITEA_TOKEN)") def attach_asset(file_path: str, release_id: str) -> dict: token = resolve_token() p = Path(file_path) if not p.is_file(): raise FileNotFoundError(f"Asset file not found: {file_path}") url = f"{GITEA_BASE}/api/v1/repos/{OWNER}/{REPO}/releases/{release_id}/assets" filename = p.name boundary = "----NovaBoundary7MAgYbk" body = ( f"--{boundary}\r\n" f'Content-Disposition: form-data; name="name"\r\n\r\n' f"{filename}\r\n" f"--{boundary}\r\n" f'Content-Disposition: form-data; name="attachment"; filename="{filename}"\r\n' f"Content-Type: application/octet-stream\r\n\r\n" ).encode() + p.read_bytes() + f"\r\n--{boundary}--\r\n".encode() req = urllib.request.Request( url, data=body, headers={ "Authorization": f"token {token}", "Content-Type": f"multipart/form-data; boundary={boundary}", }, method="POST", ) try: resp = urllib.request.urlopen(req, timeout=60) return json.loads(resp.read()) except urllib.error.HTTPError as e: err = e.read().decode()[:300] raise RuntimeError(f"HTTP {e.code} attaching {filename} to release {release_id}: {err}") from e if __name__ == "__main__": if len(sys.argv) < 3: print("Usage: attach_release_asset.py [...] ") sys.exit(1) asset_paths = sys.argv[1:-1] release_id = sys.argv[-1] for idx, path in enumerate(asset_paths): result = attach_asset(path, release_id) primary = " (primary)" if idx == 0 and len(asset_paths) > 1 else "" print(f"Attached{primary}: {result.get('name')} → release {release_id} (asset id {result.get('id')})")