From 4e36ddce59e5a7a2d737af9d376a3eb5abd5f1f9 Mon Sep 17 00:00:00 2001 From: Clemens Lange Date: Wed, 6 May 2026 00:01:41 +0200 Subject: [PATCH] Add release image publishing workflow --- .github/workflows/images.yml | 182 ++++++++++++++++ README.md | 91 +++++++- scripts/__init__.py | 1 + scripts/root_images.py | 395 +++++++++++++++++++++++++++++++++++ tests/test_root_images.py | 134 ++++++++++++ 5 files changed, 794 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/images.yml create mode 100644 scripts/__init__.py create mode 100644 scripts/root_images.py create mode 100644 tests/test_root_images.py diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml new file mode 100644 index 0000000..c77d8ce --- /dev/null +++ b/.github/workflows/images.yml @@ -0,0 +1,182 @@ +name: Build ROOT Images + +on: + workflow_dispatch: + inputs: + dry_run: + description: "Discover and update metadata without pushing images or committing README changes" + required: false + type: boolean + default: false + force_rebuild: + description: "Rebuild release images even when the GHCR tag already exists" + required: false + type: boolean + default: false + schedule: + - cron: "37 2 * * *" + pull_request: + paths: + - ".github/workflows/images.yml" + - "README.md" + - "scripts/**" + - "tests/**" + +permissions: + contents: read + +env: + REGISTRY: ghcr.io + IMAGE_NAMESPACE: root-project + IMAGE_NAME: root + +jobs: + validate: + name: Validate automation + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Run actionlint + uses: rhysd/actionlint@v1.7.12 + + - name: Run unit tests + run: python -m unittest discover -s tests -v + + discover: + name: Discover images + runs-on: ubuntu-latest + needs: validate + permissions: + contents: read + packages: read + outputs: + release_matrix: ${{ steps.plan.outputs.release_matrix }} + release_count: ${{ steps.plan.outputs.release_count }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + if: ${{ github.event_name != 'pull_request' && inputs.dry_run != true && github.ref_name == github.event.repository.default_branch }} + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Discover release matrix + id: plan + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/${{ env.IMAGE_NAME }} + run: | + args=(--image "$IMAGE" --plan-json build-plan.json --github-output "$GITHUB_OUTPUT") + if [[ "${{ github.event_name }}" != "pull_request" && "${{ inputs.dry_run }}" != "true" && "${{ inputs.force_rebuild }}" != "true" && "${{ github.ref_name }}" == "${{ github.event.repository.default_branch }}" ]]; then + args+=(--skip-existing) + fi + python scripts/root_images.py plan "${args[@]}" + + - name: Print discovery summary + run: | + python - <<'PY' + import json + from pathlib import Path + + plan = json.loads(Path("build-plan.json").read_text()) + print(f"Image: {plan['image']}") + print(f"Release images to build: {len(plan['release_images'])}") + PY + + build-release: + name: Build release image + runs-on: ubuntu-latest + needs: discover + if: ${{ github.event_name != 'pull_request' && inputs.dry_run != true && github.ref_name == github.event.repository.default_branch && needs.discover.outputs.release_count != '0' }} + timeout-minutes: 120 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + max-parallel: 3 + matrix: ${{ fromJSON(needs.discover.outputs.release_matrix) }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push release image + uses: docker/build-push-action@v7 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + pull: true + push: true + tags: ${{ join(matrix.tags, ',') }} + build-args: ${{ join(matrix.build_args, ',') }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ matrix.root_version }} + + - name: Smoke test release image + run: docker run --rm "${{ matrix.primary_tag }}" root-config --version + + publish-metadata: + name: Publish README metadata + runs-on: ubuntu-latest + needs: + - discover + - build-release + if: >- + ${{ + always() && + github.event_name != 'pull_request' && + inputs.dry_run != true && + github.ref_name == github.event.repository.default_branch && + needs.discover.result == 'success' && + (needs.build-release.result == 'success' || needs.build-release.result == 'skipped') + }} + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.ref_name }} + + - name: Regenerate README image metadata + id: plan + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/${{ env.IMAGE_NAME }} + run: | + python scripts/root_images.py plan \ + --image "$IMAGE" \ + --plan-json build-plan.json + python scripts/root_images.py update-readme --plan-json build-plan.json + + - name: Commit README update + run: | + if git diff --quiet README.md; then + echo "README image metadata is already up to date" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add README.md + git commit -m "Update ROOT image metadata" + git push diff --git a/README.md b/README.md index fe447af..25f39e7 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,82 @@ Maintained by: [the ROOT team](https://root.cern/) and collaborators Where to get help: [the ROOT forum](https://root-forum.cern.ch/) -## Supported tags and respective Dockerfile links - -### Latest images +## GHCR images + + + +Images built by the GitHub Actions automation are published to GHCR. + +Pull the latest supported stable release with: + +``` +docker pull ghcr.io/root-project/root:latest +``` + +### Active release images + +| Image tag | ROOT tag | Dockerfile | +| --- | --- | --- | +| `ghcr.io/root-project/root:6.38.04-ubuntu24.04` | `v6-38-04` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.38.02-ubuntu24.04` | `v6-38-02` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.38.00-ubuntu24.04` | `v6-38-00` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.36.12-ubuntu24.04` | `v6-36-12` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.36.10-ubuntu24.04` | `v6-36-10` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.36.08-ubuntu24.04` | `v6-36-08` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.36.06-ubuntu24.04` | `v6-36-06` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.36.04-ubuntu24.04` | `v6-36-04` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.36.02-ubuntu24.04` | `v6-36-02` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.36.00-ubuntu24.04` | `v6-36-00` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.34.10-ubuntu24.04` | `v6-34-10` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.34.08-ubuntu24.04` | `v6-34-08` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.34.06-ubuntu24.04` | `v6-34-06` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.34.04-ubuntu24.04` | `v6-34-04` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.34.02-ubuntu24.04` | `v6-34-02` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.34.00-ubuntu24.04` | `v6-34-00` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.32.22-ubuntu24.04` | `v6-32-22` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.32.20-ubuntu24.04` | `v6-32-20` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.32.18-ubuntu24.04` | `v6-32-18` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.32.16-ubuntu24.04` | `v6-32-16` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.32.14-ubuntu24.04` | `v6-32-14` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.32.12-ubuntu24.04` | `v6-32-12` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.32.10-ubuntu24.04` | `v6-32-10` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.32.08-ubuntu24.04` | `v6-32-08` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.32.06-ubuntu24.04` | `v6-32-06` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.32.04-ubuntu24.04` | `v6-32-04` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.32.02-ubuntu24.04` | `v6-32-02` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.32.00-ubuntu24.04` | `v6-32-00` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.30.10-ubuntu24.04` | `v6-30-10` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.30.08-ubuntu22.04` | `v6-30-08` | [ubuntu2204/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2204/Dockerfile) | +| `ghcr.io/root-project/root:6.30.06-ubuntu22.04` | `v6-30-06` | [ubuntu2204/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2204/Dockerfile) | +| `ghcr.io/root-project/root:6.30.04-ubuntu22.04` | `v6-30-04` | [ubuntu2204/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2204/Dockerfile) | +| `ghcr.io/root-project/root:6.30.02-ubuntu22.04` | `v6-30-02` | [ubuntu2204/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2204/Dockerfile) | +| `ghcr.io/root-project/root:6.28.14-ubuntu24.04` | `v6-28-14` | [ubuntu2404/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2404/Dockerfile) | +| `ghcr.io/root-project/root:6.28.12-ubuntu22.04` | `v6-28-12` | [ubuntu2204/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2204/Dockerfile) | +| `ghcr.io/root-project/root:6.28.10-ubuntu22.04` | `v6-28-10` | [ubuntu2204/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2204/Dockerfile) | +| `ghcr.io/root-project/root:6.26.18-ubuntu22.04` | `v6-26-18` | [ubuntu2204/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2204/Dockerfile) | +| `ghcr.io/root-project/root:6.26.16-ubuntu22.04` | `v6-26-16` | [ubuntu2204/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2204/Dockerfile) | +| `ghcr.io/root-project/root:6.26.14-ubuntu22.04` | `v6-26-14` | [ubuntu2204/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu2204/Dockerfile) | +| `ghcr.io/root-project/root:6.25.01-ubuntu20.04` | `v6-25-01` | [ubuntu20/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu20/Dockerfile) | +| `ghcr.io/root-project/root:6.24.08-ubuntu20.04` | `v6-24-08` | [ubuntu20/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu20/Dockerfile) | +| `ghcr.io/root-project/root:6.24.06-ubuntu20.04` | `v6-24-06` | [ubuntu20/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu20/Dockerfile) | +| `ghcr.io/root-project/root:6.24.04-ubuntu20.04` | `v6-24-04` | [ubuntu20/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu20/Dockerfile) | +| `ghcr.io/root-project/root:6.24.02-ubuntu20.04` | `v6-24-02` | [ubuntu20/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu20/Dockerfile) | +| `ghcr.io/root-project/root:6.24.00-ubuntu20.04` | `v6-24-00` | [ubuntu20/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu20/Dockerfile) | +| `ghcr.io/root-project/root:6.23.01-ubuntu20.04` | `v6-23-01` | [ubuntu20/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu20/Dockerfile) | +| `ghcr.io/root-project/root:6.22.08-ubuntu20.04` | `v6-22-08` | [ubuntu20/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu20/Dockerfile) | +| `ghcr.io/root-project/root:6.22.06-ubuntu20.04` | `v6-22-06` | [ubuntu20/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu20/Dockerfile) | +| `ghcr.io/root-project/root:6.22.02-ubuntu20.04` | `v6-22-02` | [ubuntu20/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu20/Dockerfile) | +| `ghcr.io/root-project/root:6.22.00-ubuntu20.04` | `v6-22-00` | [ubuntu20/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu20/Dockerfile) | +| `ghcr.io/root-project/root:6.20.08-ubuntu20.04` | `v6-20-08` | [ubuntu20/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu20/Dockerfile) | +| `ghcr.io/root-project/root:6.20.06-ubuntu20.04` | `v6-20-06` | [ubuntu20/Dockerfile](https://github.com/root-project/root-docker/blob/master/ubuntu20/Dockerfile) | + +`ghcr.io/root-project/root:latest` points to `6.38.04-ubuntu24.04`. + + + +## Legacy Docker Hub tags and Dockerfile links + +### Latest legacy image * [`latest`](https://github.com/root-project/root-docker/blob/6.38.00-ubuntu25.10/ubuntu2510/Dockerfile) -> [`6.38.00-ubuntu25.10`](https://github.com/root-project/root-docker/blob/6.38.00-ubuntu25.10/ubuntu2510/Dockerfile) @@ -45,15 +118,15 @@ Different images provide ROOT installations built with different C++ standards. The ROOT team provides several Docker images. In order to run containers, you must [have Docker installed](https://www.docker.com/community-edition#/download). You can start a container by running the following command in your terminal which will start the latest stable release of ROOT: ``` -docker run --rm -it rootproject/root +docker run --rm -it ghcr.io/root-project/root:latest ``` Note that the `--rm` flag tells Docker to remove the container, together with its data, once it is shut down. In order to persist data, it is recommended to mount a directory on the container. For example, to mount your home directory on Linux and Mac, run: ``` -docker run --rm -it -v ~:/userhome --user $(id -u) rootproject/root +docker run --rm -it -v ~:/userhome --user $(id -u) ghcr.io/root-project/root:latest ``` On Windows, you have to specify the full path to your user directory: ``` -docker run --rm -it -v C:\\Users\\Username:/userhome rootproject/root +docker run --rm -it -v C:\\Users\\Username:/userhome ghcr.io/root-project/root:latest ``` The `-v` option tells Docker to mount the home directory (`~`) to `/userhome` in the container. `--user $(id -u)` signs us in with the same userid as in the host in order to allow reading/writing to the mounted directory. This is not necessary on Windows. Mac and Windows users does however have to mark the drives or areas they want to mount as shared in the Docker application under settings. @@ -67,7 +140,7 @@ The `-v` option tells Docker to mount the home directory (`~`) to `/userhome` in To use graphics, make sure you are in an X11 session and run the following command: ``` -docker run -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix --rm -it --user $(id -u) rootproject/root root +docker run -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix --rm -it --user $(id -u) ghcr.io/root-project/root:latest root ``` On some platforms (e.g., Arch Linux) connections to the X server must be allowed explicitly by executing `xhost local:root` or an equivalent command (see e.g. [this page](https://wiki.archlinux.org/index.php/Xhost) for more information on `xhost` and its possible security implications). @@ -83,7 +156,7 @@ xhost + $ip ``` This will start XQuartz and whitelist your local IP address. Finally, you can start up ROOT with the following command: ``` -docker run --rm -it -v /tmp/.X11-unix:/tmp/.X11-unix -e DISPLAY=$ip:0 rootproject/root root +docker run --rm -it -v /tmp/.X11-unix:/tmp/.X11-unix -e DISPLAY=$ip:0 ghcr.io/root-project/root:latest root ``` ##### Windows @@ -91,7 +164,7 @@ To enable graphics, you must have [Xming](https://sourceforge.net/projects/xming ``Add-Content 'C:\Program Files (x86)\Xming\X0.hosts' "`r`n10.0.75.2"`` Restart Xming and start the container with the following command: ``` -docker run --rm -it -e DISPLAY=10.0.75.1:0 rootproject/root +docker run --rm -it -e DISPLAY=10.0.75.1:0 ghcr.io/root-project/root:latest ``` ## Examples diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..4284a1c --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Project-local automation scripts.""" diff --git a/scripts/root_images.py b/scripts/root_images.py new file mode 100644 index 0000000..d006a92 --- /dev/null +++ b/scripts/root_images.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +"""Discover, build-plan, and document ROOT release container images. + +The script intentionally uses only the Python standard library so that it can +run both locally and on GitHub-hosted runners without a bootstrap step. +""" + +from __future__ import annotations + +import argparse +import html +import json +import re +import subprocess +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Sequence + + +ROOT_REPO_URL = "https://github.com/root-project/root.git" +DOWNLOAD_INDEX_URL = "https://root.cern/download/" +README_BEGIN = "" +README_END = "" + +STABLE_TAG_RE = re.compile(r"^v(?P\d+)-(?P\d+)-(?P\d+)$") +ROOT_BINARY_RE = re.compile( + r"root_v(?P\d+\.\d+\.\d+)\.Linux-(?Pubuntu\d+(?:\.\d+)?)-" + r"x86_64-[^\"'<> ]+?\.tar\.gz" +) + +# Contexts that are suitable for the primary release image. Keep this list in +# newest-supported-LTS-first order. It intentionally omits short-lived Ubuntu +# releases such as 25.10 even if this repository has a matching Dockerfile. +UBUNTU_LTS_CONTEXTS = { + "ubuntu24.04": "ubuntu2404", + "ubuntu22.04": "ubuntu2204", + "ubuntu20.04": "ubuntu20", +} +UBUNTU_LTS_PRIORITY = tuple(UBUNTU_LTS_CONTEXTS) +UBUNTU_PLATFORM_ALIASES = { + "ubuntu24": "ubuntu24.04", + "ubuntu24.04": "ubuntu24.04", + "ubuntu22": "ubuntu22.04", + "ubuntu22.04": "ubuntu22.04", + "ubuntu20": "ubuntu20.04", + "ubuntu20.04": "ubuntu20.04", +} + + +@dataclass(frozen=True) +class RootTag: + name: str + major: int + minor: int + patch: int + + @property + def version(self) -> str: + return f"{self.major}.{self.minor:02d}.{self.patch:02d}" + + @property + def sort_key(self) -> tuple[int, int, int]: + return (self.major, self.minor, self.patch) + + +def run(args: Sequence[str]) -> str: + completed = subprocess.run(args, check=True, text=True, stdout=subprocess.PIPE) + return completed.stdout + + +def fetch_text(url: str) -> str: + with urllib.request.urlopen(url, timeout=60) as response: + return response.read().decode("utf-8", errors="replace") + + +def stable_root_tag(tag: str) -> RootTag | None: + match = STABLE_TAG_RE.match(tag) + if not match: + return None + return RootTag( + name=tag, + major=int(match.group("major")), + minor=int(match.group("minor")), + patch=int(match.group("patch")), + ) + + +def parse_ref_names(ls_remote_output: str, ref_prefix: str) -> list[str]: + refs: list[str] = [] + for line in ls_remote_output.splitlines(): + if not line.strip(): + continue + try: + _sha, ref = line.split(None, 1) + except ValueError: + continue + if ref.startswith(ref_prefix): + refs.append(ref.removeprefix(ref_prefix)) + return refs + + +def fetch_upstream_tags(root_repo_url: str = ROOT_REPO_URL) -> list[str]: + output = run(["git", "ls-remote", "--tags", "--refs", root_repo_url, "refs/tags/v*"]) + return parse_ref_names(output, "refs/tags/") + + +def parse_root_binaries(download_index_html: str) -> dict[str, dict[str, str]]: + binaries: dict[str, dict[str, str]] = {} + for match in ROOT_BINARY_RE.finditer(html.unescape(download_index_html)): + filename = match.group(0) + version = match.group("version") + platform = UBUNTU_PLATFORM_ALIASES.get(match.group("platform")) + if platform not in UBUNTU_LTS_CONTEXTS: + continue + binaries.setdefault(version, {})[platform] = filename + return binaries + + +def choose_primary_ubuntu_binary( + version: str, binaries: dict[str, dict[str, str]] +) -> tuple[str, str, str] | None: + version_binaries = binaries.get(version, {}) + for platform in UBUNTU_LTS_PRIORITY: + root_bin = version_binaries.get(platform) + if root_bin: + return platform, UBUNTU_LTS_CONTEXTS[platform], root_bin + return None + + +def image_exists(image_ref: str, inspector: Callable[[str], bool] | None = None) -> bool: + if inspector: + return inspector(image_ref) + result = subprocess.run( + ["docker", "buildx", "imagetools", "inspect", image_ref], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + ) + return result.returncode == 0 + + +def release_image_entry( + tag: RootTag, + image: str, + platform: str, + context: str, + root_bin: str, + latest: bool, +) -> dict[str, object]: + image_tag = f"{tag.version}-{platform}" + tags = [f"{image}:{image_tag}"] + if latest: + tags.append(f"{image}:latest") + return { + "kind": "release", + "root_tag": tag.name, + "root_version": tag.version, + "root_bin": root_bin, + "platform": platform, + "context": context, + "dockerfile": f"{context}/Dockerfile", + "image_tag": image_tag, + "tags": tags, + "primary_tag": tags[0], + "build_args": [f"ROOT_BIN={root_bin}"], + "readme_dockerfile_url": ( + f"https://github.com/root-project/root-docker/blob/master/{context}/Dockerfile" + ), + } + + +def build_plan( + *, + upstream_tags: Sequence[str], + download_index_html: str, + image: str, + skip_existing: bool = False, + inspector: Callable[[str], bool] | None = None, +) -> dict[str, object]: + stable_tags = sorted( + (parsed for parsed in (stable_root_tag(tag) for tag in upstream_tags) if parsed), + key=lambda item: item.sort_key, + ) + binaries = parse_root_binaries(download_index_html) + + release_candidates: list[tuple[RootTag, str, str, str]] = [] + tags_without_binary: list[str] = [] + + for tag in stable_tags: + binary = choose_primary_ubuntu_binary(tag.version, binaries) + if not binary: + tags_without_binary.append(tag.name) + continue + platform, context, root_bin = binary + release_candidates.append((tag, platform, context, root_bin)) + + all_release_images: list[dict[str, object]] = [] + latest_release_tag = release_candidates[-1][0].name if release_candidates else None + for tag, platform, context, root_bin in release_candidates: + all_release_images.append( + release_image_entry( + tag=tag, + image=image, + platform=platform, + context=context, + root_bin=root_bin, + latest=tag.name == latest_release_tag, + ) + ) + + release_images = [ + entry + for entry in all_release_images + if not skip_existing or not image_exists(str(entry["primary_tag"]), inspector) + ] + + return { + "image": image, + "all_release_images": all_release_images, + "release_images": release_images, + "tags_without_binary": tags_without_binary, + } + + +def matrix(entries: Sequence[dict[str, object]]) -> dict[str, object]: + return {"include": list(entries)} + + +def write_github_output(path: Path, outputs: dict[str, str]) -> None: + with path.open("a", encoding="utf-8") as output: + for name, value in outputs.items(): + output.write(f"{name}<<__ROOT_IMAGES__\n{value}\n__ROOT_IMAGES__\n") + + +def render_readme_section(plan: dict[str, object]) -> str: + image = str(plan["image"]) + releases = list(plan["all_release_images"]) + + lines = [ + README_BEGIN, + "", + "Images built by the GitHub Actions automation are published to GHCR.", + "", + "Pull the latest supported stable release with:", + "", + "```", + f"docker pull {image}:latest", + "```", + "", + "### Active release images", + "", + ] + + if releases: + lines.extend( + [ + "| Image tag | ROOT tag | Dockerfile |", + "| --- | --- | --- |", + ] + ) + for entry in sorted( + releases, + key=lambda item: tuple(int(part) for part in str(item["root_version"]).split(".")), + reverse=True, + ): + image_tag = str(entry["image_tag"]) + root_tag = str(entry["root_tag"]) + dockerfile_url = str(entry["readme_dockerfile_url"]) + lines.append( + f"| `{image}:{image_tag}` | `{root_tag}` | " + f"[{entry['dockerfile']}]({dockerfile_url}) |" + ) + lines.append("") + latest = next((entry for entry in releases if f"{image}:latest" in entry["tags"]), None) + if latest: + lines.append(f"`{image}:latest` points to `{latest['image_tag']}`.") + lines.append("") + else: + lines.extend(["No active release images were discovered.", ""]) + + lines.append(README_END) + return "\n".join(lines) + + +def update_readme(readme_path: Path, plan: dict[str, object]) -> None: + readme = readme_path.read_text(encoding="utf-8") + section = render_readme_section(plan) + if README_BEGIN not in readme or README_END not in readme: + raise ValueError( + f"{readme_path} must contain {README_BEGIN} and {README_END} markers" + ) + before, rest = readme.split(README_BEGIN, 1) + _old, after = rest.split(README_END, 1) + readme_path.write_text(before + section + after, encoding="utf-8") + + +def load_plan(path: Path) -> dict[str, object]: + return json.loads(path.read_text(encoding="utf-8")) + + +def command_plan(args: argparse.Namespace) -> int: + upstream_tags = ( + Path(args.tags_file).read_text(encoding="utf-8").splitlines() + if args.tags_file + else fetch_upstream_tags(args.root_repo_url) + ) + download_index_html = ( + Path(args.download_index_file).read_text(encoding="utf-8") + if args.download_index_file + else fetch_text(args.download_index_url) + ) + + plan = build_plan( + upstream_tags=upstream_tags, + download_index_html=download_index_html, + image=args.image, + skip_existing=args.skip_existing, + ) + + plan_json = json.dumps(plan, sort_keys=True, indent=2) + if args.plan_json: + Path(args.plan_json).write_text(plan_json + "\n", encoding="utf-8") + else: + print(plan_json) + + if args.github_output: + write_github_output( + Path(args.github_output), + { + "release_matrix": json.dumps(matrix(plan["release_images"])), + "release_count": str(len(plan["release_images"])), + }, + ) + + return 0 + + +def command_update_readme(args: argparse.Namespace) -> int: + update_readme(Path(args.readme), load_plan(Path(args.plan_json))) + return 0 + + +def command_local_build_args(args: argparse.Namespace) -> int: + plan = load_plan(Path(args.plan_json)) + releases = list(plan["all_release_images"]) + if not releases: + raise SystemExit("No release images are available in the plan") + latest = max( + releases, + key=lambda item: tuple(int(part) for part in str(item["root_version"]).split(".")), + ) + for build_arg in latest["build_args"]: + print(build_arg) + return 0 + + +def parser() -> argparse.ArgumentParser: + argument_parser = argparse.ArgumentParser(description=__doc__) + subcommands = argument_parser.add_subparsers(dest="command", required=True) + + plan_parser = subcommands.add_parser("plan", help="discover images and write a build plan") + plan_parser.add_argument("--image", default="ghcr.io/root-project/root") + plan_parser.add_argument("--root-repo-url", default=ROOT_REPO_URL) + plan_parser.add_argument("--download-index-url", default=DOWNLOAD_INDEX_URL) + plan_parser.add_argument("--tags-file") + plan_parser.add_argument("--download-index-file") + plan_parser.add_argument("--skip-existing", action="store_true") + plan_parser.add_argument("--plan-json") + plan_parser.add_argument("--github-output") + plan_parser.set_defaults(func=command_plan) + + readme_parser = subcommands.add_parser( + "update-readme", help="replace the generated README image section" + ) + readme_parser.add_argument("--plan-json", required=True) + readme_parser.add_argument("--readme", default="README.md") + readme_parser.set_defaults(func=command_update_readme) + + local_parser = subcommands.add_parser( + "local-build-args", help="print build args for the newest release image in a plan" + ) + local_parser.add_argument("--plan-json", required=True) + local_parser.set_defaults(func=command_local_build_args) + + return argument_parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = parser().parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_root_images.py b/tests/test_root_images.py new file mode 100644 index 0000000..8a36204 --- /dev/null +++ b/tests/test_root_images.py @@ -0,0 +1,134 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from scripts import root_images + + +DOWNLOAD_HTML = """ +x +x +x +x +x +x +x +""" + + +class RootImagesTest(unittest.TestCase): + def test_stable_tag_parser_excludes_rc_and_suffixes(self): + parsed = root_images.stable_root_tag("v6-38-04") + self.assertIsNotNone(parsed) + self.assertEqual(parsed.version, "6.38.04") + self.assertIsNone(root_images.stable_root_tag("v6-38-04-rc1")) + self.assertIsNone(root_images.stable_root_tag("v6-30-00a")) + + def test_primary_ubuntu_binary_prefers_newest_supported_lts(self): + binaries = root_images.parse_root_binaries(DOWNLOAD_HTML) + self.assertEqual( + root_images.choose_primary_ubuntu_binary("6.38.04", binaries), + ( + "ubuntu24.04", + "ubuntu2404", + "root_v6.38.04.Linux-ubuntu24.04-x86_64-gcc13.3.tar.gz", + ), + ) + self.assertEqual( + root_images.choose_primary_ubuntu_binary("6.36.10", binaries), + ( + "ubuntu22.04", + "ubuntu2204", + "root_v6.36.10.Linux-ubuntu22.04-x86_64-gcc11.4.tar.gz", + ), + ) + self.assertEqual( + root_images.choose_primary_ubuntu_binary("6.28.12", binaries), + ( + "ubuntu22.04", + "ubuntu2204", + "root_v6.28.12.Linux-ubuntu22-x86_64-gcc11.4.tar.gz", + ), + ) + self.assertEqual( + root_images.choose_primary_ubuntu_binary("6.26.14", binaries), + ( + "ubuntu20.04", + "ubuntu20", + "root_v6.26.14.Linux-ubuntu20-x86_64-gcc9.4.tar.gz", + ), + ) + + def test_build_plan_filters_existing_release_images(self): + plan = root_images.build_plan( + upstream_tags=[ + "v6-38-00", + "v6-38-04", + "v6-38-04-rc1", + "v6-37-01", + "v6-36-10", + ], + download_index_html=DOWNLOAD_HTML, + image="ghcr.io/example/root", + skip_existing=True, + inspector=lambda image: image.endswith(":6.38.04-ubuntu24.04"), + ) + + self.assertEqual( + [entry["image_tag"] for entry in plan["all_release_images"]], + ["6.36.10-ubuntu22.04", "6.38.04-ubuntu24.04"], + ) + self.assertEqual( + [entry["image_tag"] for entry in plan["release_images"]], + ["6.36.10-ubuntu22.04"], + ) + latest = plan["all_release_images"][-1] + self.assertIn("ghcr.io/example/root:latest", latest["tags"]) + + def test_latest_uses_newest_release_with_binary(self): + plan = root_images.build_plan( + upstream_tags=["v6-38-04", "v6-40-00"], + download_index_html=DOWNLOAD_HTML, + image="ghcr.io/example/root", + ) + + self.assertEqual(plan["tags_without_binary"], ["v6-40-00"]) + self.assertEqual( + plan["all_release_images"][-1]["image_tag"], + "6.38.04-ubuntu24.04", + ) + self.assertIn( + "ghcr.io/example/root:latest", + plan["all_release_images"][-1]["tags"], + ) + + def test_readme_generation_and_update(self): + plan = root_images.build_plan( + upstream_tags=["v6-38-04"], + download_index_html=DOWNLOAD_HTML, + image="ghcr.io/root-project/root", + ) + section = root_images.render_readme_section(plan) + self.assertIn("ghcr.io/root-project/root:6.38.04-ubuntu24.04", section) + self.assertNotIn("Nightly branch images", section) + + with tempfile.TemporaryDirectory() as directory: + readme = Path(directory) / "README.md" + readme.write_text( + "before\n" + f"{root_images.README_BEGIN}\nold\n{root_images.README_END}\n" + "after\n", + encoding="utf-8", + ) + plan_path = Path(directory) / "plan.json" + plan_path.write_text(json.dumps(plan), encoding="utf-8") + root_images.update_readme(readme, plan) + content = readme.read_text(encoding="utf-8") + self.assertIn("before", content) + self.assertIn("after", content) + self.assertNotIn("old", content) + + +if __name__ == "__main__": + unittest.main()