#!/usr/bin/env python3 """Attach the built CLI artifact to a gitea release (stdlib only). Usage: python3 scripts/release.py [--base-url URL] [--owner O] [--repo R] [--env-root DIR] Flow (REQ-22, RESEARCH §2): resolve the forge token from the sanctioned env files via ``scripts/_env_secrets.py`` — never the process environment, asserted non-empty BEFORE the first API call (v1.8 lesson: an empty token makes gitea answer 404, masking the auth failure) -> compute the artifact sha256 from the FILE itself (G-017: there is no sha argument, ever) -> build the release notes (Summary / Requirements / Verification / CLI install + usage with the digest inline / Docker usage) -> create-or-get the release by tag -> idempotent attach (same name AND size -> skip) -> multipart attach (field ``attachment``, ``?name=``, ``Authorization: token ``) under bounded retry (3 attempts, 2s/4s backoff on connection errors and 5xx). Exit codes: 0 only on a confirmed or idempotent attach; 1 after a remote failure — bounded retries exhausted OR a permanent 4xx — both print the machine-readable ``RELEASE_PENDING `` marker on stdout for the ship harness's release_pending escalation (tag+merge is never blocked); 2 for local precondition errors (missing artifact, token, or config — nothing was attempted remotely, so no marker). Gitea JSON error bodies are included in failure diagnostics. Token values are never printed. """ import argparse import hashlib import http.client import json import os import sys import time import urllib.error import urllib.parse import urllib.request _SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) if _SCRIPTS_DIR not in sys.path: sys.path.insert(0, _SCRIPTS_DIR) import _env_secrets # sibling import (scripts/ dir on sys.path) TIMEOUT_SECONDS = 30 MAX_ATTEMPTS = 3 BACKOFF_SECONDS = (2, 4) ATTACHMENT_FIELD = "attachment" # Fixed, distinctive boundary: a hand-rolled multipart body needs our own # delimiter; collision with artifact bytes is negligible at this length. MULTIPART_BOUNDARY = "coreci-broker-release-boundary-9a7f3c2e6b41" class ReleaseError(Exception): """Release-attacher failure base.""" class TransientFailure(ReleaseError): """Connection error or 5xx persisting past the bounded retries.""" class GiteaApiError(ReleaseError): """Permanent API answer (4xx or malformed) carrying gitea's detail.""" def __init__(self, context, status, body): self.status = status self.body = body super().__init__("{} -> HTTP {}: {}".format( context, status, _error_detail(body))) def repo_root(): return os.path.dirname(_SCRIPTS_DIR) def load_release_defaults(root=None): """Read the release block from .ciagent/config.json ({} if absent).""" path = os.path.join(root or repo_root(), ".ciagent", "config.json") try: with open(path, "r", encoding="utf-8") as handle: config = json.load(handle) except (OSError, ValueError): return {} block = config.get("release") return block if isinstance(block, dict) else {} def _error_detail(body): """Summarize a gitea JSON error body ({"message": ...} shape).""" try: payload = json.loads(body.decode("utf-8", "replace")) except ValueError: text = body.decode("utf-8", "replace").strip() return text[:200] if text else "" if isinstance(payload, dict) and payload.get("message"): return str(payload["message"]) return json.dumps(payload)[:200] if not isinstance(payload, str) else payload[:200] def _api_base(base_url, owner, repo): return "{}/api/v1/repos/{}/{}".format(base_url.rstrip("/"), owner, repo) def _image_reference(base_url, owner, repo): host = urllib.parse.urlsplit(base_url).netloc or base_url return "{}/{}/{}".format(host.rstrip("/"), owner, repo) def build_notes(tag, artifact_name, sha256, image_reference): """Release-notes body (RESEARCH §2.3 model; REQ-22 CLI + docker docs).""" lines = [ "## Summary", "", "coreci-broker {tag}: the broker packaged as a self-contained CLI" " zipapp artifact. {artifact} is built by scripts/build_cli.py with" " a deterministic normalization pass - the same sources rebuild" " byte-identical on the same environment, so the digest below is" " meaningful across rebuilds.".format(tag=tag, artifact=artifact_name), "", "## Requirements", "", "REQ-22: from the first CLI-building release onward, every gitea" " release attaches the built coreci-broker artifact alongside the" " existing source archive. The upload resolves the forge token only" " via the sanctioned .env* files (never the process environment)," " asserted non-empty before the first API call.", "", "## Verification", "", "The artifact sha256 in the CLI section below is computed from the" " attached file itself (G-017 - never an argument, never" " hand-pasted). Re-run the cumulative suite on the tagged commit:", "", " python3 -m unittest discover -s tests", "", "## CLI install + usage", "", "1. Download {artifact} from this release's assets.".format( artifact=artifact_name), "2. Install: chmod +x {artifact} then ./{artifact}, or run it" " directly with python3 {artifact} (Python 3.11+).".format( artifact=artifact_name), "3. Check the build: ./{artifact} --version prints {tag}.".format( artifact=artifact_name, tag=tag), "4. The env contract (every BROKER_* variable) is documented in the" " README; state is anchored by BROKER_ENV_PATH - the .env file's" " directory is the state dir and relative paths stay CWD-relative.", "5. First boot prints BROKER_ADMIN_PASS=... exactly once and creates" " the .env, broker.db, and broker.audit.log state files with mode" " 0600; the printed admin pass is not shown again - store it.", "", "Artifact sha256 (computed from the attached file):", "", " sha256:{sha} {artifact}".format(sha=sha256, artifact=artifact_name), "", "## Docker usage", "", "docker pull {image}:{tag}".format(image=image_reference, tag=tag), "", "docker run -p 8765:8765 -v broker-state:/state {image}:{tag}".format( image=image_reference, tag=tag), "", "State lives on the /state volume (WORKDIR /state; the first boot" " generates the .env, broker.db, and broker.audit.log there) -" " mount a persistent volume or the admin pass and any registered" " apps are lost with the container.", "", ] return "\n".join(lines) def _request_once(method, url, token, data=None, headers=None): """One HTTP exchange; returns (status, body). HTTPError becomes a completed answer (status + gitea error body); connection-level errors propagate for the caller's retry policy.""" request = urllib.request.Request(url, data=data, method=method) request.add_header("Authorization", "token {}".format(token)) for key, value in (headers or {}).items(): request.add_header(key, value) try: with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response: return response.status, response.read() except urllib.error.HTTPError as error: return error.code, error.read() def _request(method, url, token, data=None, headers=None, max_attempts=None, backoff=None): """Bounded-retry HTTP: retries connection errors and 5xx answers. Returns (status, body) for any answer below 500. A 4xx returns immediately (permanent — surfaced with the gitea error body). Raises TransientFailure once the retry budget is exhausted. """ attempts = MAX_ATTEMPTS if max_attempts is None else int(max_attempts) delays = BACKOFF_SECONDS if backoff is None else tuple(backoff) failure = "unknown failure" for attempt in range(1, attempts + 1): try: status, body = _request_once(method, url, token, data, headers) except (OSError, http.client.HTTPException) as error: status = None failure = "connection error: {}: {}".format( type(error).__name__, error) else: if status < 500: return status, body failure = "HTTP {}: {}".format(status, _error_detail(body)) if attempt < attempts and delays: time.sleep(delays[min(attempt - 1, len(delays) - 1)]) raise TransientFailure( "{} {} failed after {} attempt(s): {}".format( method, url, attempts, failure)) def _release_id_from(payload_body, context, status): try: return int(json.loads(payload_body)["id"]) except (ValueError, KeyError, TypeError): raise GiteaApiError(context + " returned malformed JSON", status, payload_body) def _get_or_create_release(base, tag, token, notes): by_tag = base + "/releases/tags/" + urllib.parse.quote(tag, safe="") status, body = _request("GET", by_tag, token) if status == 200: return _release_id_from(body, "GET release by tag", status) if status != 404: # A 401/403 here is a genuine auth failure — the non-empty token # assert already ran, so this 404-vs-auth ambiguity is resolved. raise GiteaApiError("GET release by tag failed", status, body) payload = json.dumps({ "tag_name": tag, "name": "{}: coreci-broker release".format(tag), "body": notes, "draft": False, "prerelease": False, }).encode("utf-8") status, body = _request( "POST", base + "/releases", token, data=payload, headers={"Content-Type": "application/json"}) if status not in (200, 201): raise GiteaApiError("create release failed", status, body) return _release_id_from(body, "create release", status) def _assets_url(base, release_id): return "{}/releases/{}/assets".format(base, release_id) def _list_assets(base, release_id, token): status, body = _request("GET", _assets_url(base, release_id), token) if status != 200: raise GiteaApiError("list release assets failed", status, body) try: assets = json.loads(body) except ValueError: raise GiteaApiError( "list release assets returned malformed JSON", status, body) if not isinstance(assets, list): raise GiteaApiError( "list release assets returned non-list JSON", status, body) return assets def _asset_already_attached(assets, name, size): """Idempotency: same name AND same size means the upload succeeded on an earlier (possibly retried) run.""" for asset in assets: if not isinstance(asset, dict) or asset.get("name") != name: continue try: if int(asset.get("size")) == size: return True except (TypeError, ValueError): continue return False def _multipart_body(field, filename, content): head = ( "--{boundary}\r\n" "Content-Disposition: form-data; name=\"{field}\";" " filename=\"{filename}\"\r\n" "Content-Type: application/octet-stream\r\n" "\r\n" ).format(boundary=MULTIPART_BOUNDARY, field=field, filename=filename).encode("utf-8") tail = "\r\n--{}--\r\n".format(MULTIPART_BOUNDARY).encode("utf-8") return head + content + tail def _attach_asset(base, release_id, token, name, content): url = "{}?{}".format( _assets_url(base, release_id), urllib.parse.urlencode({"name": name})) status, body = _request( "POST", url, token, data=_multipart_body(ATTACHMENT_FIELD, name, content), headers={ "Content-Type": "multipart/form-data; boundary={}".format(MULTIPART_BOUNDARY), }) if status not in (200, 201): raise GiteaApiError("attach asset failed", status, body) try: return json.loads(body) except ValueError: return {} def main(argv=None): parser = argparse.ArgumentParser( description="Attach the built CLI artifact to a gitea release (REQ-22)." ) parser.add_argument("tag", help="release tag, e.g. v0.2.1") parser.add_argument("artifact", help="path to the built .pyz artifact") parser.add_argument("--base-url", help="gitea base URL (default: .ciagent/config.json release.base_url)") parser.add_argument("--owner", help="gitea owner (default: release.owner)") parser.add_argument("--repo", help="gitea repo (default: release.repo)") parser.add_argument("--env-root", help="root dir for the sanctioned .env* files (default: repo root)") args = parser.parse_args(argv) defaults = load_release_defaults() base_url = args.base_url or defaults.get("base_url") owner = args.owner or defaults.get("owner") repo = args.repo or defaults.get("repo") missing = [flag for flag, value in ( ("--base-url", base_url), ("--owner", owner), ("--repo", repo)) if not value] if missing: print("ERROR: missing gitea target {}; pass the flag(s) or extend" " the .ciagent/config.json release block".format( ", ".join(missing)), file=sys.stderr) return 2 if not os.path.isfile(args.artifact): print("ERROR: artifact not found: {}".format(args.artifact), file=sys.stderr) return 2 # Token FIRST, from the sanctioned env files only, non-empty enforced — # strictly before any HTTP call (v1.8: an empty token makes gitea answer # 404 and masks the auth failure). try: token = _env_secrets.get_token(root=args.env_root) except _env_secrets.SecretsError as error: print("ERROR: {}".format(error), file=sys.stderr) return 2 with open(args.artifact, "rb") as handle: content = handle.read() # G-017: the digest is computed from the artifact file itself — there is # no sha argument, ever. sha256 = hashlib.sha256(content).hexdigest() name = os.path.basename(args.artifact) size = len(content) notes = build_notes(args.tag, name, sha256, _image_reference(base_url, owner, repo)) base = _api_base(base_url, owner, repo) try: release_id = _get_or_create_release(base, args.tag, token, notes) if _asset_already_attached(_list_assets(base, release_id, token), name, size): print("attach skipped (idempotent): {} already on {}/{} release" " {} (sha256 {})".format(name, owner, repo, args.tag, sha256)) return 0 attachment = _attach_asset(base, release_id, token, name, content) asset_id = attachment.get("id", "?") if isinstance(attachment, dict) else "?" print("attached: {} -> {}/{} release {} (asset id {}, sha256 {})".format( name, owner, repo, args.tag, asset_id, sha256)) return 0 except (TransientFailure, GiteaApiError) as error: print("ERROR: {}".format(error), file=sys.stderr) print("RELEASE_PENDING {} {}".format(args.tag, args.artifact)) return 1 if __name__ == "__main__": sys.exit(main())