Files
broker/scripts/release.py
T
coreci da15bda6d2 docs(milestone): complete cli-packaging
---ci---
phase: 3
milestone: v0.2
status: complete
requirements:
  covered: [REQ-21, REQ-22, REQ-23]
  partial: []
---/ci---
2026-10-11 00:32:26 +00:00

380 lines
15 KiB
Python

#!/usr/bin/env python3
"""Attach the built CLI artifact to a gitea release (stdlib only).
Usage:
python3 scripts/release.py <tag> <artifact>
[--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 <T>``) 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 <tag> <artifact>`` 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 "<empty body>"
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())