CI / Tests, lint (push) Successful in 12s
- 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.
210 lines
7.1 KiB
Python
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())
|