feat: bump moves here, and verify-base takes images by name
CI / Tests, lint (push) Successful in 12s
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.
This commit is contained in:
1 parent
9232b54c6d
commit
ae39af4665
6 files changed
+459
-8
No files matched your search
@@ -22,6 +22,7 @@ with its CIS result and SBOM attached (NetOrk/netork#1390).
|
|||||||
| **Signature** (`bin/sign.sh`) | A cosign signature on the reference and on every platform image behind it, with the CIS result and the SBOM as in-toto attestations. Nothing goes to a public transparency log. |
|
| **Signature** (`bin/sign.sh`) | A cosign signature on the reference and on every platform image behind it, with the CIS result and the SBOM as in-toto attestations. Nothing goes to a public transparency log. |
|
||||||
| **Verification** (`bin/verify-base.sh`, `bin/verify-signature.sh`) | Every `FROM` image of a Dockerfile is pinned by digest and signed with the key in `cosign.pub`. |
|
| **Verification** (`bin/verify-base.sh`, `bin/verify-signature.sh`) | Every `FROM` image of a Dockerfile is pinned by digest and signed with the key in `cosign.pub`. |
|
||||||
| **Verdict** (`image_tools/verify.py`) | Whether a rebuild improves on the pinned image, by netOrk's matcher (kb-server), for a base's nightly. |
|
| **Verdict** (`image_tools/verify.py`) | Whether a rebuild improves on the pinned image, by netOrk's matcher (kb-server), for a base's nightly. |
|
||||||
|
| **Bump** (`image_tools/bump.py`) | One pull request in every repository of a `dependents.toml` that moves only the pin, and that repository's workflow started. |
|
||||||
|
|
||||||
## Using it in a workflow
|
## Using it in a workflow
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Move the pin in every dependent
|
||||||
|
description: >-
|
||||||
|
Open or update one pull request in every repository of dependents.toml that
|
||||||
|
moves only the pin, and start that repository's workflow (image_tools/bump.py).
|
||||||
|
The caller passes BUMP_TOKEN, the netork-bot account's token, as env.
|
||||||
|
inputs:
|
||||||
|
image:
|
||||||
|
description: The image whose pin moves, e.g. netork/base-os
|
||||||
|
required: true
|
||||||
|
ref:
|
||||||
|
description: The new reference, <image>:<version>@<digest>
|
||||||
|
required: true
|
||||||
|
version:
|
||||||
|
description: The new version
|
||||||
|
required: true
|
||||||
|
report:
|
||||||
|
description: The pull requests' body, a file
|
||||||
|
default: report.md
|
||||||
|
dependents:
|
||||||
|
description: The repositories that pin the image
|
||||||
|
default: dependents.toml
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- shell: bash
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ inputs.image }}
|
||||||
|
REF: ${{ inputs.ref }}
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
REPORT: ${{ inputs.report }}
|
||||||
|
DEPENDENTS: ${{ inputs.dependents }}
|
||||||
|
PYTHONPATH: ${{ github.action_path }}/../..
|
||||||
|
run: |
|
||||||
|
python3 -m image_tools.bump "$IMAGE" "$REF" "$VERSION" \
|
||||||
|
--report "$REPORT" --dependents "$DEPENDENTS"
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
name: Verify the base images
|
name: Verify the base images
|
||||||
description: >-
|
description: >-
|
||||||
Every image the Dockerfiles build on is signed with netOrk's key, checked
|
Every image the Dockerfiles build on, and every image named in refs, is
|
||||||
before the build (NetOrk/netork#1390, bin/verify-base.sh).
|
signed with netOrk's key, checked before the build (NetOrk/netork#1390,
|
||||||
|
bin/verify-base.sh, bin/verify-signature.sh). Give one or both.
|
||||||
inputs:
|
inputs:
|
||||||
dockerfiles:
|
dockerfiles:
|
||||||
description: The Dockerfiles, separated by spaces, relative to the workspace
|
description: Dockerfiles whose FROM images to verify, separated by spaces
|
||||||
required: true
|
default: ""
|
||||||
|
refs:
|
||||||
|
description: >-
|
||||||
|
Images to verify, <image>@<digest>, separated by spaces: for a FROM the
|
||||||
|
Dockerfile only knows as an argument
|
||||||
|
default: ""
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
@@ -16,5 +22,18 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
DOCKERFILES: ${{ inputs.dockerfiles }}
|
DOCKERFILES: ${{ inputs.dockerfiles }}
|
||||||
# shellcheck disable=SC2086 # the list splits on purpose
|
REFS: ${{ inputs.refs }}
|
||||||
run: bash "${{ github.action_path }}/../../bin/verify-base.sh" $DOCKERFILES
|
# The lists split on spaces on purpose.
|
||||||
|
run: |
|
||||||
|
if [ -z "$DOCKERFILES$REFS" ]; then
|
||||||
|
echo "verify-base: give dockerfiles, refs or both" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -n "$DOCKERFILES" ]; then
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
bash "${{ github.action_path }}/../../bin/verify-base.sh" $DOCKERFILES
|
||||||
|
fi
|
||||||
|
if [ -n "$REFS" ]; then
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
bash "${{ github.action_path }}/../../bin/verify-signature.sh" $REFS
|
||||||
|
fi
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""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())
|
||||||
+22
-2
@@ -17,8 +17,10 @@ ROOT = Path(__file__).resolve().parents[1]
|
|||||||
ACTIONS = sorted((ROOT / "actions").glob("*/action.yml"))
|
ACTIONS = sorted((ROOT / "actions").glob("*/action.yml"))
|
||||||
|
|
||||||
|
|
||||||
def test_the_four_actions():
|
def test_the_actions():
|
||||||
assert [a.parent.name for a in ACTIONS] == ["judge", "sign", "verdict", "verify-base"]
|
names = [a.parent.name for a in ACTIONS]
|
||||||
|
|
||||||
|
assert names == ["bump", "judge", "sign", "verdict", "verify-base"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("action", ACTIONS, ids=lambda a: a.parent.name)
|
@pytest.mark.parametrize("action", ACTIONS, ids=lambda a: a.parent.name)
|
||||||
@@ -46,3 +48,21 @@ def test_a_verdict_that_could_not_be_reached_fails_the_step():
|
|||||||
assert spec["outputs"]["improved"]["value"] == "${{ steps.verdict.outputs.improved }}"
|
assert spec["outputs"]["improved"]["value"] == "${{ steps.verdict.outputs.improved }}"
|
||||||
assert "['error']" in step["run"] and "sys.exit(" in step["run"]
|
assert "['error']" in step["run"] and "sys.exit(" in step["run"]
|
||||||
assert "cat report.md" in step["run"].splitlines()
|
assert "cat report.md" in step["run"].splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bumping_needs_its_token_from_the_caller():
|
||||||
|
text = (ROOT / "actions/bump/action.yml").read_text()
|
||||||
|
|
||||||
|
assert "BUMP_TOKEN" in text and "secrets." not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_base_can_be_named_where_the_dockerfile_cannot():
|
||||||
|
"""base-python builds FROM ${BASE_OS}, an argument the nightly sets, and copies
|
||||||
|
CPython from Docker's python:3.12-slim, which is not signed with netOrk's key.
|
||||||
|
It names the one image to verify instead."""
|
||||||
|
spec = yaml.safe_load((ROOT / "actions/verify-base/action.yml").read_text())
|
||||||
|
runs = "\n".join(step["run"] for step in spec["runs"]["steps"])
|
||||||
|
|
||||||
|
assert not spec["inputs"]["dockerfiles"].get("required")
|
||||||
|
assert "refs" in spec["inputs"]
|
||||||
|
assert "bin/verify-signature.sh" in runs and "bin/verify-base.sh" in runs
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Moving a pin in the repos that use an image (#1308).
|
||||||
|
|
||||||
|
When an image improves, its repo opens (or updates) one pull request in every
|
||||||
|
repo that pins it, rewriting only the pin, and starts that repo's own nightly
|
||||||
|
run, which rebuilds on the new pin and verifies itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
|
from image_tools.bump import Dependent, bump_all, load_dependents, open_or_update_pr, rewrite_pin
|
||||||
|
|
||||||
|
OLD = "registry.netork.io/netork/base-os:13-20261001.1@sha256:" + "a" * 64
|
||||||
|
NEW = "registry.netork.io/netork/base-os:13-20261020.1@sha256:" + "b" * 64
|
||||||
|
|
||||||
|
|
||||||
|
class TestRewritePin:
|
||||||
|
def test_every_from_line_moves(self):
|
||||||
|
text = f"FROM {OLD} AS build\nRUN x\nFROM {OLD}\n"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
rewrite_pin(text, "netork/base-os", NEW) == f"FROM {NEW} AS build\nRUN x\nFROM {NEW}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_compose_image_line_moves(self):
|
||||||
|
text = f"services:\n cache:\n image: {OLD}\n"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
rewrite_pin(text, "netork/base-os", NEW) == f"services:\n cache:\n image: {NEW}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_another_image_stays(self):
|
||||||
|
other = "registry.netork.io/netork/base-python:3.12.15-13-20261001.1@sha256:" + "c" * 64
|
||||||
|
text = f"FROM {other}\n"
|
||||||
|
|
||||||
|
assert rewrite_pin(text, "netork/base-os", NEW) == text
|
||||||
|
|
||||||
|
def test_an_image_whose_name_only_starts_alike_stays(self):
|
||||||
|
text = "FROM registry.netork.io/netork/base-os-extra:1@sha256:" + "d" * 64 + "\n"
|
||||||
|
|
||||||
|
assert rewrite_pin(text, "netork/base-os", NEW) == text
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGitea:
|
||||||
|
def __init__(self, files: dict[str, str], prs: list[dict] | None = None, branches=()):
|
||||||
|
self.files = dict(files)
|
||||||
|
self.prs = list(prs or [])
|
||||||
|
self.branches = set(branches)
|
||||||
|
self.calls: list[tuple[str, str]] = []
|
||||||
|
self.missing_workflows: set[str] = set()
|
||||||
|
self.dispatched: list[str] = []
|
||||||
|
|
||||||
|
def __call__(self, method: str, path: str, body: dict | None = None):
|
||||||
|
self.calls.append((method, path))
|
||||||
|
if method == "GET" and path.count("/") == 3: # /repos/<owner>/<repo>
|
||||||
|
return {"full_name": path.split("/repos/", 1)[1]}
|
||||||
|
if method == "GET" and "/contents/" in path:
|
||||||
|
name = path.split("/contents/", 1)[1].split("?", 1)[0]
|
||||||
|
content = base64.b64encode(self.files[name].encode()).decode()
|
||||||
|
return {"content": content, "sha": f"sha-{name}"}
|
||||||
|
if method == "PUT" and "/contents/" in path:
|
||||||
|
name = path.split("/contents/", 1)[1]
|
||||||
|
self.files[name] = base64.b64decode(body["content"]).decode()
|
||||||
|
return {}
|
||||||
|
if method == "POST" and path.endswith("/branches"):
|
||||||
|
self.branches.add(body["new_branch_name"])
|
||||||
|
return {}
|
||||||
|
if method == "GET" and "/branches/" in path:
|
||||||
|
name = path.rsplit("/branches/", 1)[1]
|
||||||
|
if name not in self.branches:
|
||||||
|
raise FileNotFoundError(name)
|
||||||
|
return {}
|
||||||
|
if method == "GET" and "/pulls?" in path:
|
||||||
|
return [p for p in self.prs if p["state"] == "open"]
|
||||||
|
if method == "POST" and path.endswith("/pulls"):
|
||||||
|
pr = {"number": 7, "state": "open", "head": {"ref": body["head"]}, "body": body["body"]}
|
||||||
|
self.prs.append(pr)
|
||||||
|
return pr
|
||||||
|
if method == "POST" and path.endswith("/dispatches"):
|
||||||
|
workflow = path.split("/workflows/", 1)[1].split("/", 1)[0]
|
||||||
|
if workflow in self.missing_workflows:
|
||||||
|
raise FileNotFoundError(path)
|
||||||
|
self.dispatched.append(workflow)
|
||||||
|
return {}
|
||||||
|
if method == "PATCH" and "/pulls/" in path:
|
||||||
|
self.prs[0]["body"] = body["body"]
|
||||||
|
return self.prs[0]
|
||||||
|
raise AssertionError(f"unexpected {method} {path}")
|
||||||
|
|
||||||
|
|
||||||
|
DEP = Dependent(repo="NetOrk/base-python", files=("Dockerfile",), workflow="nightly.yml")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPullRequest:
|
||||||
|
def test_it_opens_one_pr_with_only_the_pin_changed(self):
|
||||||
|
api = FakeGitea({"Dockerfile": f"FROM {OLD}\nRUN echo hi\n"})
|
||||||
|
|
||||||
|
pr = open_or_update_pr(api, DEP, "netork/base-os", NEW, "13-20261020.1", "report")
|
||||||
|
|
||||||
|
assert pr["number"] == 7
|
||||||
|
assert api.files["Dockerfile"] == f"FROM {NEW}\nRUN echo hi\n"
|
||||||
|
assert "chore/base-os-13-20261020.1" in api.branches
|
||||||
|
assert api.prs[0]["body"] == "report"
|
||||||
|
|
||||||
|
def test_a_second_run_updates_the_open_pr(self):
|
||||||
|
branch = "chore/base-os-13-20261020.1"
|
||||||
|
api = FakeGitea(
|
||||||
|
{"Dockerfile": f"FROM {NEW}\n"},
|
||||||
|
prs=[{"number": 7, "state": "open", "head": {"ref": branch}, "body": "old"}],
|
||||||
|
branches={branch},
|
||||||
|
)
|
||||||
|
|
||||||
|
open_or_update_pr(api, DEP, "netork/base-os", NEW, "13-20261020.1", "new report")
|
||||||
|
|
||||||
|
assert len(api.prs) == 1
|
||||||
|
assert api.prs[0]["body"] == "new report"
|
||||||
|
assert not [c for c in api.calls if c[0] == "POST" and c[1].endswith("/branches")]
|
||||||
|
|
||||||
|
def test_a_file_without_the_pin_is_left_alone(self):
|
||||||
|
api = FakeGitea({"Dockerfile": "FROM something-else\n"})
|
||||||
|
|
||||||
|
assert open_or_update_pr(api, DEP, "netork/base-os", NEW, "13-20261020.1", "r") is None
|
||||||
|
assert not [c for c in api.calls if c[0] in ("PUT", "POST")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_dependents_are_listed_in_the_repo(tmp_path):
|
||||||
|
path = tmp_path / "dependents.toml"
|
||||||
|
path.write_text(
|
||||||
|
'[[dependent]]\nrepo = "NetOrk/netork"\nfiles = ["docker/ui/Dockerfile"]\n'
|
||||||
|
'workflow = "ui-nightly.yml"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert load_dependents(path) == [
|
||||||
|
Dependent("NetOrk/netork", ("docker/ui/Dockerfile",), "ui-nightly.yml")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestEveryDependent:
|
||||||
|
"""2026-10-22: netork's dependents entry named ui-nightly.yml, which does not
|
||||||
|
exist yet; the dispatch's 404 ended the run with a traceback after the last
|
||||||
|
pull request. A dependent that cannot be started is named, the others go
|
||||||
|
on, and the run fails at the end, as for a repo the token cannot see."""
|
||||||
|
|
||||||
|
DEPS = [
|
||||||
|
Dependent(repo="NetOrk/netork", files=("Dockerfile",), workflow="ui-nightly.yml"),
|
||||||
|
Dependent(repo="NetOrk/valkey", files=("Dockerfile",), workflow="nightly.yml"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_a_missing_workflow_is_named_and_the_rest_goes_on(self):
|
||||||
|
api = FakeGitea({"Dockerfile": f"FROM {OLD}\n"})
|
||||||
|
api.missing_workflows = {"ui-nightly.yml"}
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
ok = bump_all(api, self.DEPS, "netork/base-os", NEW, "13-20261020.1", "r", lines.append)
|
||||||
|
|
||||||
|
assert not ok
|
||||||
|
assert api.dispatched == ["nightly.yml"]
|
||||||
|
assert any("NetOrk/netork" in ln and "ui-nightly.yml" in ln for ln in lines)
|
||||||
|
assert any(ln.startswith("NetOrk/valkey: PR #7") for ln in lines)
|
||||||
|
|
||||||
|
def test_all_well_is_ok(self):
|
||||||
|
api = FakeGitea({"Dockerfile": f"FROM {OLD}\n"})
|
||||||
|
|
||||||
|
assert bump_all(api, self.DEPS[1:], "netork/base-os", NEW, "13-20261020.1", "r", print)
|
||||||
|
assert api.dispatched == ["nightly.yml"]
|
||||||
Reference in new issue
Block a user