Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ _default:
alias help := _default

export COVERAGE_RCFILE := justfile_directory() + "/.coveragerc"
uv_update_script := justfile_directory() + "/tools/uv_update.py"
update_pyproject_toml_script := justfile_directory() + "/tools/update_pyproject_toml.py"
docker_folder_exists := path_exists(invocation_directory() + '/docker')
src_folder_exists := path_exists(invocation_directory() + '/src')
tests_folder_exists := path_exists(invocation_directory() + '/tests')
Expand All @@ -31,20 +31,25 @@ random_string := uuid()

# === Update dependencies ===

# Update Docker images in the CircleCI config.
[private]
update-circle-ci-config:
uv run --script tools/update_circle_ci_config.py

# Update Docker base images in Dockerfiles.
[private]
update-docker-base-images:
uv run --script tools/dockerfile_base_image_update.py
uv run --script tools/update_dockerfile_base_image.py

# Update GitHub Actions in GitHub workflow YAML files.
[private]
update-github-actions:
uv run --script tools/github_action_update.py
uv run --script tools/update_github_action.py

# Update direct and indirect Python dependencies.
[private]
update-py-dependencies folder:
uv run --frozen --no-sync --directory "{{ folder }}" --script "{{ uv_update_script }}"
uv run --frozen --no-sync --directory "{{ folder }}" --script "{{ update_pyproject_toml_script }}"
uv sync --upgrade --quiet --no-progress --directory "{{ folder }}"

# Update direct and indirect JavaScript dependencies.
Expand Down
88 changes: 88 additions & 0 deletions tools/update_circle_ci_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# /// script
# requires-python = ">=3.14"
# dependencies = [
# "packaging>=26.0",
# "requests>=2.32.5",
# ]
# ///

"""CircleCI config updater script finds images and updates to the latest versions."""

import re
import sys
from functools import cache
from pathlib import Path

import requests
from packaging.version import InvalidVersion, Version


@cache
def get_available_tags(image: str) -> list[str]:
"""Fetch available tags for a Docker image from Docker Hub."""
namespace, repository = image.split("/", maxsplit=1) if "/" in image else ("library", image)
url = f"https://registry.hub.docker.com/v2/namespaces/{namespace}/repositories/{repository}/tags?page_size=100"
tags = []
while url:
response = requests.get(url, timeout=10)
response.raise_for_status()
json = response.json()
tags.extend([result["name"] for result in json.get("results", [])])
url = json.get("next")
return tags


def split_tag(tag: str) -> tuple[Version | None, str]:
"""Split the tag in a version and suffix. If the tag doesn't contain a valid version, the version is None."""
version_string, suffix = tag.split("-", maxsplit=1) if "-" in tag else (tag, "")
try:
version = Version(version_string)
except InvalidVersion:
version = None
return version, suffix


def assemble_tag(version: Version, suffix: str) -> str:
"""Assemble a tag from the version and suffix."""
return f"{version}-{suffix}" if suffix else str(version)


def get_latest_tag(image: str, current_tag: str) -> str:
"""Find the latest compatible tag for an image. Keeps the same non-numerical parts while upgrading the version."""
current_version, current_suffix = split_tag(current_tag)
if current_version is None:
return current_tag # Can't determine a newer tag if the tag doesn't contain a valid version
latest_version = current_version
for tag in get_available_tags(image):
version, suffix = split_tag(tag)
if version is None:
continue # Ignore tags if the version is not valid
if version.is_prerelease:
continue # Ignore tags if the version is a prereleease
if suffix != current_suffix:
continue # Ignore tags with a different suffix because we don't want to change e.g. fat to slim
latest_version = max(latest_version, version)
return assemble_tag(latest_version, current_suffix)


def update_config_yml_line(line: str) -> str:
"""Update the config YAML if the line contains an image statement, otherwise return the line unchanged."""
if match := re.search(r"image: (?P<image>[\w\d\./-]+):(?P<tag>[\d\w\.\-]+)", line):
image = match.group("image")
tag = match.group("tag")
return line.replace(tag, get_latest_tag(image, tag))
return line


def update_circle_ci_config() -> int:
"""Update images in the Circle CI config."""
config_yml = Path.cwd() / ".circleci" / "config.yml"
old_lines = config_yml.read_text().splitlines()
new_lines = [update_config_yml_line(line) for line in old_lines]
if old_lines != new_lines:
config_yml.write_text("\n".join(new_lines) + "\n")
return 0


if __name__ == "__main__":
sys.exit(update_circle_ci_config())
File renamed without changes.
File renamed without changes.