Files
Christian Manivong ae39af4665
CI / Tests, lint (push) Successful in 12s
feat: bump moves here, and verify-base takes images by name
- image_tools/bump.py from base-os, with an action. base-python used it from
  a pinned checkout of base-os, with the bot's token; one place serves both.
  A dependent whose workflow does not exist (netork's ui-nightly.yml, on
  2026-10-22) is named and the others go on; the run fails at the end, as
  for a repository the token cannot see. bump_all holds that loop, tested.
- verify-base takes refs besides Dockerfiles: base-python builds FROM
  ${BASE_OS}, which its nightly sets, and copies CPython from Docker's
  python:3.12-slim, which is not signed with netOrk's key.
2026-10-22 17:51:05 +02:00

210 lines
7.1 KiB
Python

"""Move a pin in the repos that use an image, and start their own run (#1308).
When an image improves, its repo opens or updates one pull request in every
dependent (``dependents.toml``), rewriting only the pin, then dispatches the
dependent's workflow, which rebuilds on the new pin and verifies itself.
python -m image_tools.bump <image> <new-ref> <version> --report report.md
Talks to Gitea with ``BUMP_TOKEN`` (the netork-bot account); standard library only.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import tomllib
import urllib.error
import urllib.request
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
GITEA_URL = os.environ.get("GITEA_URL", "https://git.netork.io")
REGISTRY = "registry.netork.io"
#: A pinned reference: registry/repo:tag@sha256:<64 hex>.
_REF = re.compile(r"^registry\.netork\.io/[a-z0-9./-]+:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$")
Api = Callable[..., object]
@dataclass(frozen=True)
class Dependent:
repo: str
files: tuple[str, ...]
workflow: str
def load_dependents(path: Path) -> list[Dependent]:
data = tomllib.loads(path.read_text())
return [
Dependent(d["repo"], tuple(d["files"]), d["workflow"]) for d in data.get("dependent", [])
]
def rewrite_pin(text: str, image: str, new_ref: str) -> str:
"""*text* with every pinned reference of *image* replaced by *new_ref*."""
if not _REF.match(new_ref):
raise ValueError(f"not a pinned reference: {new_ref!r}")
# The host may be written as ${REGISTRY_HOST:-registry.netork.io} (netork's
# compose files); that spelling stays, only the path, tag and digest move.
pattern = re.compile(
rf"({re.escape(REGISTRY)}\}}?/){re.escape(image)}:[A-Za-z0-9._-]+@sha256:[0-9a-f]{{64}}"
)
tail = new_ref.split("/", 1)[1]
return pattern.sub(lambda m: m.group(1) + tail, text)
def _branch(image: str, version: str) -> str:
return f"chore/{image.rsplit('/', 1)[-1]}-{version}"
def open_or_update_pr(
api: Api, dep: Dependent, image: str, new_ref: str, version: str, report: str
) -> dict | None:
"""One PR in *dep* moving *image* to *new_ref*; None when no file pins it.
A second run for the same version works on the branch the first one made:
files already moved stay as they are, and the open PR gets the new report.
"""
owner_repo = f"/repos/{dep.repo}"
# Gitea answers 404 for a repo the token's account may not see, and for its
# files too; that read as "no pin to move" for every dependent (2026-10-22).
try:
api("GET", owner_repo)
except FileNotFoundError:
raise PermissionError(f"{dep.repo} is not visible to the bump token's account") from None
branch = _branch(image, version)
try:
api("GET", f"{owner_repo}/branches/{branch}")
branch_exists = True
except (FileNotFoundError, urllib.error.HTTPError):
branch_exists = False
ref = branch if branch_exists else "main"
changes = {}
for name in dep.files:
try:
current = api("GET", f"{owner_repo}/contents/{name}?ref={ref}")
except FileNotFoundError:
continue # not there yet (a repo still being set up): no pin to move
text = base64.b64decode(current["content"]).decode()
moved = rewrite_pin(text, image, new_ref)
if moved != text:
changes[name] = (moved, current["sha"])
if not changes and not branch_exists:
return None
if not branch_exists:
api(
"POST", f"{owner_repo}/branches", {"new_branch_name": branch, "old_branch_name": "main"}
)
title = f"chore(images): move {image} to {version}"
for name, (text, sha) in changes.items():
api(
"PUT",
f"{owner_repo}/contents/{name}",
{
"content": base64.b64encode(text.encode()).decode(),
"sha": sha,
"branch": branch,
"message": title,
},
)
open_prs = api("GET", f"{owner_repo}/pulls?state=open&limit=50")
for pr in open_prs:
if pr["head"]["ref"] == branch:
return api("PATCH", f"{owner_repo}/pulls/{pr['number']}", {"body": report})
return api(
"POST",
f"{owner_repo}/pulls",
{"head": branch, "base": "main", "title": title, "body": report},
)
def dispatch(api: Api, dep: Dependent, inputs: dict[str, str]) -> None:
"""Start *dep*'s own workflow on main; one without a workflow gets the PR only."""
if not dep.workflow:
return
api(
"POST",
f"/repos/{dep.repo}/actions/workflows/{dep.workflow}/dispatches",
{"ref": "main", "inputs": inputs},
)
def bump_all(
api: Api,
deps: list[Dependent],
image: str,
new_ref: str,
version: str,
report: str,
say: Callable[[str], None],
) -> bool:
"""A pull request in every dependent and its run started; False when one failed.
A repo the token cannot see, or a workflow that does not exist, is named and
the others go on: one wrong entry must not keep the rest at the old pin.
"""
ok = True
for dep in deps:
try:
pr = open_or_update_pr(api, dep, image, new_ref, version, report)
except PermissionError as exc:
say(f"{dep.repo}: {exc}")
ok = False
continue
say(f"{dep.repo}: {'PR #' + str(pr['number']) if pr else 'no pin to move'}")
if not pr:
continue
try:
dispatch(api, dep, {"base_ref": new_ref})
except FileNotFoundError:
say(f"{dep.repo}: workflow {dep.workflow} not found; its pull request stands")
ok = False
return ok
def gitea(method: str, path: str, body: dict | None = None): # pragma: no cover - network seam
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(
f"{GITEA_URL}/api/v1{path}",
data=data,
method=method,
headers={
"Authorization": f"token {os.environ['BUMP_TOKEN']}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 - https
raw = response.read()
except urllib.error.HTTPError as exc:
if exc.code == 404:
raise FileNotFoundError(path) from exc
raise
return json.loads(raw) if raw else {}
def main() -> int: # pragma: no cover - CLI seam
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("image", help="e.g. netork/base-os")
parser.add_argument("new_ref")
parser.add_argument("version")
parser.add_argument("--report", required=True)
parser.add_argument("--dependents", default="dependents.toml")
args = parser.parse_args()
report = Path(args.report).read_text()
deps = load_dependents(Path(args.dependents))
ok = bump_all(gitea, deps, args.image, args.new_ref, args.version, report, print)
return 0 if ok else 1
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())