da15bda6d2
---ci--- phase: 3 milestone: v0.2 status: complete requirements: covered: [REQ-21, REQ-22, REQ-23] partial: [] ---/ci---
380 lines
15 KiB
Python
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()) |