CI Pipeline / best-practices (push) Successful in 8s
Auto-version and Release / release (push) Failing after 13s
CI Pipeline / documentation (push) Successful in 5s
CI Pipeline / canary (push) Successful in 33s
CI Pipeline / lint (push) Failing after 41s
CI Pipeline / type-check (push) Successful in 27s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / unit-tests (push) Successful in 59s
CI Pipeline / required-checks (push) Successful in 1s
deploy / deploy (push) Successful in 37s
- scripts/gitea_auth.py: single credential ladder (env -> 0600 token file -> git credential fill); 6 consumers migrated off direct os.environ reads - rune_operator: consolidate 5 duplicated failure blocks into _terminalize_execution_failure; fix 3 fault-injection gaps: (1) dual-module PreparedTool identity split crashed approval-path saves, (2) fallback-model failure escaped send_task (created/pending orphan), (3) running-transition save uncaught (running-with-no-owner) - envelope task.status now agrees with canonical ledger (awaiting_approval) - orphan task_ac35f4e287c8 terminalized failed with durable evidence - tests: gitea auth ladder (6), lifecycle fault matrix (9 boundaries + restart/reload + helper contract); 1280 green in ci-venv; ratchet 352/80
126 lines
4.9 KiB
Python
126 lines
4.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Idempotent release creation for the Rune release pipeline (#192).
|
|
|
|
Extracted from the 'Create release from merged pull request' step in
|
|
``.gitea/workflows/auto-version.yml`` so both the pull_request:closed path and
|
|
the push-triggered safety net cut releases through the same code. Keeps the
|
|
tag-exists idempotency check (retry/replay safety).
|
|
|
|
Env-driven: GITEA_TOKEN, RELEASE_VERSION, PR_TITLE/PR_BODY (optional — the
|
|
push safety net has no PR context and falls back to a generic body),
|
|
RELEASE_SERVER_URL + RELEASE_REPO. The release API URLs are derived in-script
|
|
from those base coordinates (spec-gate finding 1, #192: the workflow never
|
|
passed LIST_API_URL/RELEASE_API_URL — KeyError at runtime on both paths), so
|
|
the script is self-sufficient; explicit LIST_API_URL/RELEASE_API_URL env
|
|
values still override the derived defaults for direct/manual use.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Any
|
|
import sys as _sys
|
|
_SCRIPT_DIR = str(__import__('pathlib').Path(__file__).resolve().parent)
|
|
if _SCRIPT_DIR not in _sys.path:
|
|
_sys.path.insert(0, _SCRIPT_DIR)
|
|
import gitea_auth
|
|
|
|
# Env-contract (#192 spec-gate finding 1): derived in-script, never required
|
|
# from the caller. Guarded by tests/test_workflow_script_contract.py.
|
|
DERIVED_ENV: dict[str, str] = {
|
|
"LIST_API_URL": "RELEASE_SERVER_URL + RELEASE_REPO",
|
|
"RELEASE_API_URL": "RELEASE_SERVER_URL + RELEASE_REPO",
|
|
}
|
|
|
|
|
|
def derive_api_urls(server_url: str, repo: str) -> tuple[str, str]:
|
|
"""Derive (list_api_url, release_api_url) from the workflow base coordinates."""
|
|
base = server_url.rstrip("/") + "/api/v1/repos/" + repo
|
|
return base + "/releases?limit=50", base + "/releases"
|
|
|
|
|
|
def build_release_payload(version: str, pr_title: str, pr_body: str) -> dict[str, Any]:
|
|
if pr_title:
|
|
body = f"## {pr_title}\n\n{pr_body}"
|
|
else:
|
|
body = "## Release (push-triggered safety net)\n\nCut automatically by the release safety net (#192) after a merge whose closed-event never arrived."
|
|
return {
|
|
"tag_name": f"v{version}",
|
|
"target_commitish": "master",
|
|
"name": f"Rune v{version}",
|
|
"body": body,
|
|
"draft": False,
|
|
"prerelease": False,
|
|
}
|
|
|
|
|
|
def release_exists(releases: list[dict[str, Any]], version: str) -> bool:
|
|
"""True when a release for ``v<version>`` already exists (idempotency check)."""
|
|
return any(r.get("tag_name") == f"v{version}" for r in releases)
|
|
|
|
|
|
def list_releases(list_api_url: str, token: str) -> list[dict[str, Any]]:
|
|
req = urllib.request.Request(list_api_url, headers={"Authorization": "token " + token})
|
|
with urllib.request.urlopen(req, timeout=30) as r:
|
|
return json.loads(r.read())
|
|
|
|
|
|
def create_release(release_api_url: str, token: str, payload: dict[str, Any]) -> None:
|
|
req = urllib.request.Request(
|
|
release_api_url,
|
|
data=json.dumps(payload).encode(),
|
|
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
print(f"release created ({r.status})")
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description="Idempotent Gitea release creation (#192)")
|
|
parser.add_argument("--dry-run", action="store_true", help="print the payload without executing any API call")
|
|
args = parser.parse_args(argv)
|
|
|
|
version = os.environ["RELEASE_VERSION"]
|
|
payload = build_release_payload(version, os.environ.get("PR_TITLE", ""), os.environ.get("PR_BODY", ""))
|
|
if args.dry_run:
|
|
print(json.dumps(payload))
|
|
return 0
|
|
|
|
token = gitea_auth.get_token()
|
|
|
|
# Derived URLs (explicit env still overrides — for direct/manual use).
|
|
server_url = os.environ["RELEASE_SERVER_URL"]
|
|
repo = os.environ["RELEASE_REPO"]
|
|
list_api_url = os.environ.get("LIST_API_URL") or derive_api_urls(server_url, repo)[0]
|
|
release_api_url = os.environ.get("RELEASE_API_URL") or derive_api_urls(server_url, repo)[1]
|
|
|
|
# Skip idempotently if a release for this tag already exists (retry/replay safety).
|
|
releases = list_releases(list_api_url, token)
|
|
if release_exists(releases, version):
|
|
print(f"Release v{version} already exists — skipping creation")
|
|
return 0
|
|
|
|
create_release(release_api_url, token, payload)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except urllib.error.HTTPError as exc:
|
|
print(
|
|
f"create_release: API failed ({exc.code}): {exc.read().decode('utf-8', 'replace')[:300]}", file=sys.stderr
|
|
)
|
|
sys.exit(1)
|
|
except urllib.error.URLError as exc:
|
|
# Connection refused / DNS failure etc. (HTTPError is a URLError
|
|
# subclass, so this only fires for transport-level failures).
|
|
print(f"create_release: API failed (transport error): {exc.reason}", file=sys.stderr)
|
|
sys.exit(1)
|