STANDARDS.md binds every repository in the NAPALM org: one default branch, Conventional Commits, the device-types contract, test-driven development, clean-code limits, a quality ratchet for existing code, packaging rules and releases by tag to PyPI. napalm-standard holds what a tool can check: the conformance check (with release rules on a tag), the quality ratchet (ported from netOrk's quality_ratchet.py), and the Gitea release from the CHANGELOG section. .gitea/workflows/driver.yml is the CI every driver calls; template/ is a new driver's starting point and passes the check as it is.
68 lines
2.2 KiB
Python
68 lines
2.2 KiB
Python
"""Create or update the Gitea release of a version tag (STANDARDS.md §9).
|
|
|
|
The upload to PyPI is twine's job in the workflow; this only writes the
|
|
release page, with that version's CHANGELOG.md section as its text. It never
|
|
creates a tag, so it never pushes anything and never starts a CI run.
|
|
|
|
Ported from netOrk's scripts/release.py (#561).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import urllib.error
|
|
import urllib.request
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
from napalm_standard.changelog import changelog_section
|
|
|
|
Api = Callable[..., Any]
|
|
|
|
|
|
class NotFound(Exception):
|
|
"""The API answered 404."""
|
|
|
|
|
|
def publish(tag: str, package: str, changelog: str, api: Api) -> Any:
|
|
"""Create the release of *tag*, or update it when it exists already."""
|
|
version = tag.removeprefix("v")
|
|
body = changelog_section(changelog, version)
|
|
if body is None:
|
|
raise ValueError(f"CHANGELOG.md has no section for [{version}]")
|
|
payload = {
|
|
"tag_name": tag,
|
|
"name": f"{package} {version}",
|
|
"body": body,
|
|
"draft": False,
|
|
"prerelease": False,
|
|
}
|
|
try:
|
|
existing = api("GET", f"/releases/tags/{tag}")
|
|
except NotFound:
|
|
return api("POST", "/releases", payload)
|
|
return api("PATCH", f"/releases/{existing['id']}", payload)
|
|
|
|
|
|
def gitea_api(base_url: str, repository: str, token: str) -> Api: # pragma: no cover - I/O
|
|
"""A small JSON client for ``/api/v1/repos/<repository>``."""
|
|
root = f"{base_url.rstrip('/')}/api/v1/repos/{repository}"
|
|
|
|
def call(method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
|
|
request = urllib.request.Request(
|
|
root + path,
|
|
method=method,
|
|
data=json.dumps(payload).encode() if payload is not None else None,
|
|
headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310
|
|
body = response.read()
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 404:
|
|
raise NotFound(path) from exc
|
|
raise
|
|
return json.loads(body) if body else {}
|
|
|
|
return call
|