Ctrl K
ComunidadGuías…

scripts/release_helper_assets.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Dateien fürs GitHub-Release: die Compose-Datei mit Helfer, fest auf den geprüften Digest, und der Text dazu.

Läuft im Job `helper-release` von `.github/workflows/release.yml`; im Repository bleibt `:1`.
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
COMPOSE = REPO_ROOT / "deploy" / "compose.standalone-mit-helfer.yml"
NOTES_DIR = REPO_ROOT / "docs" / "release-notes"

COMPOSE_NAME = "compose.standalone-mit-helfer.yml"
SECTION_NAME = "helper-digest.md"
NOTES_NAME = "release-notes.md"
TITLE_NAME = "release-title.txt"

MARKER = "<!-- nodvard-helper-digest -->"
MARKER_END = "<!-- /nodvard-helper-digest -->"

_NUM = r"(0|[1-9][0-9]{0,8})"
VERSION_RE = re.compile(rf"{_NUM}\.{_NUM}\.{_NUM}", re.ASCII)
DIGEST_RE = re.compile(r"sha256:[0-9a-f]{64}", re.ASCII)
IMAGE_RE = re.compile(r"[a-z0-9]+(\.[a-z0-9-]+)*(:[0-9]+)?(/[a-z0-9]+([._-][a-z0-9]+)*)+", re.ASCII)


class AssetError(Exception):
    """Abgelehnt; die Meldung sagt warum (Exit-Code 1)."""


def pin_compose(text: str, image: str, digest: str) -> str:
    """`text` mit `image: <image>@<digest>` statt der einzigen Zeile `image: <image>:1`."""
    tagged = f"image: {image}:1"
    lines = text.splitlines(keepends=True)
    hits = [i for i, line in enumerate(lines) if line.strip() == tagged]
    if len(hits) != 1:
        raise AssetError(f"In {COMPOSE_NAME} steht „{tagged}“ {len(hits)}-mal, erwartet genau einmal.")
    line = lines[hits[0]]
    indent = line[: len(line) - len(line.lstrip())]
    ending = line[len(line.rstrip("\r\n")):]
    lines[hits[0]] = f"{indent}image: {image}@{digest}{ending}"
    return "".join(lines)


def section(version: str, image: str, digest: str) -> str:
    return (
        f"{MARKER}\n"
        "## Update-Helfer / Update helper\n\n"
        f"`{image}@{digest}` (Version {version})\n\n"
        f"Die Datei `{COMPOSE_NAME}` an diesem Release nennt genau diesen Helfer. Vergleichen kannst du den Digest mit "
        f"`docker buildx imagetools inspect {image}:{version}` (Zeile „Digest“).\n\n"
        f"The file `{COMPOSE_NAME}` attached to this release pins exactly this helper. Compare the digest with "
        f"`docker buildx imagetools inspect {image}:{version}` (line “Digest”).\n"
        f"{MARKER_END}\n"
    )


def notes(version: str, part: str, notes_dir: Path = NOTES_DIR) -> tuple[str, str]:
    """Titel und Text eines neuen Entwurfs aus `docs/release-notes/<version>.md`; ohne die Datei nur Titel und Abschnitt."""
    path = notes_dir / f"{version}.md"
    title = f"Nodvard Deck {version}"
    if not path.is_file():
        return title, part
    body = path.read_text(encoding="utf-8")
    first, _, rest = body.partition("\n")
    if first.startswith("# "):
        title, body = first[2:].strip() or title, rest
    return title, f"{body.strip()}\n\n{part}"


def merge(existing: str, part: str) -> str:
    """Der Text eines vorhandenen Entwurfs oder Releases mit `part` statt seines Abschnitts, sonst am Ende; der Rest bleibt."""
    starts, ends = existing.count(MARKER), existing.count(MARKER_END)
    if starts == ends == 0:
        kept = existing.rstrip()
        return f"{kept}\n\n{part}" if kept else part
    start = existing.find(MARKER)
    end = existing.find(MARKER_END, start)
    if starts != 1 or ends != 1 or end < 0:
        # Was nach einer kaputten Markierung steht, kann von Hand geschrieben sein: lieber nichts ändern.
        raise AssetError(
            f"Im Text des Releases steht der Abschnitt „{MARKER}“ … „{MARKER_END}“ nicht genau einmal vollständig. "
            "Bitte den Text auf GitHub von Hand richten und den Job neu starten."
        )
    return existing[:start] + part.rstrip("\n") + existing[end + len(MARKER_END):]


def _check(version: str, image: str, digest: str) -> None:
    if VERSION_RE.fullmatch(version) is None:
        raise AssetError(f"Keine fertige Version: {version!r}.")
    if IMAGE_RE.fullmatch(image) is None:
        raise AssetError(f"Kein Image-Name: {image!r}.")
    if DIGEST_RE.fullmatch(digest) is None:
        raise AssetError(f"Kein Digest der Form sha256:<64 Hex-Zeichen>: {digest!r}.")


def write_files(version: str, image: str, digest: str, out: Path) -> None:
    _check(version, image, digest)
    pinned = pin_compose(COMPOSE.read_text(encoding="utf-8"), image, digest)
    part = section(version, image, digest)
    title, body = notes(version, part)
    out.mkdir(parents=True, exist_ok=True)
    (out / COMPOSE_NAME).write_text(pinned, encoding="utf-8")
    (out / SECTION_NAME).write_text(part, encoding="utf-8")
    (out / NOTES_NAME).write_text(body, encoding="utf-8")
    (out / TITLE_NAME).write_text(title + "\n", encoding="utf-8")


USAGE = """\
files schreibt compose.standalone-mit-helfer.yml (die Datei aus deploy/, nur das Image des Helfers per Digest statt :1),
helper-digest.md (Abschnitt mit dem Digest), release-notes.md (docs/release-notes/<version>.md ohne die Überschrift, dazu
der Abschnitt) und release-title.txt, die beiden letzten nur für einen neuen Entwurf. merge setzt den Abschnitt in den Text
eines vorhandenen Entwurfs oder Releases: genau an die Stelle des älteren Abschnitts, sonst ans Ende; der übrige Text bleibt.
"""


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0], epilog=USAGE,
                                     formatter_class=argparse.RawDescriptionHelpFormatter)
    commands = parser.add_subparsers(dest="command", required=True)
    files = commands.add_parser("files")
    files.add_argument("--version", required=True)
    files.add_argument("--image", required=True)
    files.add_argument("--digest", required=True)
    files.add_argument("--out", required=True, type=Path)
    combine = commands.add_parser("merge")
    combine.add_argument("--existing", required=True, type=Path)
    combine.add_argument("--section", required=True, type=Path)
    combine.add_argument("--out", required=True, type=Path)
    args = parser.parse_args(argv)
    try:
        if args.command == "files":
            write_files(args.version, args.image, args.digest, args.out)
        else:
            part = args.section.read_text(encoding="utf-8")
            if not (part.startswith(MARKER) and part.rstrip("\n").endswith(MARKER_END)):
                raise AssetError(f"{args.section} ist kein Abschnitt aus „files“.")
            # Als Bytes, damit Zeilenenden aus dem Editor von GitHub (\r\n) bleiben; das letzte \n hängt `gh --jq` an.
            existing = args.existing.read_bytes().decode("utf-8").removesuffix("\n")
            args.out.write_bytes(merge(existing, part).encode("utf-8"))
    except (AssetError, OSError) as exc:
        print(f"Abgelehnt: {exc}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())