refactor(update): unify version bumpers under utils/update + cli/contributing/update + scripts/github/update

Folds the parallel Docker-image and git-ref version-update plumbing
into a single subsystem so the auto-update workflow, the external
freshness tests, and the unit tests all share one module layout.

utils/
  update/
    base.py        # semver primitives shared by both backends
    docker.py      # OCI registry lookups + version: line rewriter
    repository.py  # git ls-remote walker + ref: line rewriter

cli/contributing/update/
  docker/           # python -m cli.contributing.update.docker
  repository/       # python -m cli.contributing.update.repository

scripts/github/update/
  image_versions.sh
  repository_refs.sh

Tests mirror the same shape under tests/{unit,external}/update/{docker,repository}/.

Also widens the Docker bump scope to all roles (previously gated on
`web-*`) so non-web roles like sys-svc-container get bumped too;
pins Bookwyrm's python image at 3.11-bookworm via
`# nocheck: docker-version` with a TODO until upstream supports
Python >= 3.12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.7 committed 2026-05-15 20:12:07 +02:00
1 parent 7c1ba22218
commit 98457f98fd
32 files changed
+898 -381

No files matched your search

+36 -2
View File
@@ -30,7 +30,7 @@ jobs:
run: bash scripts/github/install_update_python.sh
- name: Update Docker image versions
run: bash scripts/github/update_docker_image_versions.sh
run: bash scripts/github/update/image_versions.sh
- name: Check for changes
id: diff
@@ -43,7 +43,41 @@ jobs:
UPDATE_BRANCH_PREFIX: update/docker-image-versions
UPDATE_COMMIT_MESSAGE: "update(images): update Docker image versions"
UPDATE_PR_TITLE: "update(images): update Docker image versions"
UPDATE_PR_BODY: "Automated update of semver-based Docker image versions in `roles/web-*/config/main.yml`."
UPDATE_PR_BODY: "Automated update of semver-based Docker image versions in `roles/*/meta/services.yml`."
UPDATE_BASE_BRANCH: main
run: bash scripts/github/open_update_pr.sh roles
update-repository-refs:
name: 🌿 Update repository refs
if: vars.CI_ENABLE_AUTO_UPDATES == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install update dependencies
run: bash scripts/github/install_update_python.sh
- name: Update repository refs
run: bash scripts/github/update/repository_refs.sh
- name: Check for changes
id: diff
run: bash scripts/github/check_git_changes.sh roles
- name: Open PR if repository refs changed
if: steps.diff.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UPDATE_BRANCH_PREFIX: update/repository-refs
UPDATE_COMMIT_MESSAGE: "update(repos): bump git refs to latest semver tags"
UPDATE_PR_TITLE: "update(repos): bump git refs to latest semver tags"
UPDATE_PR_BODY: "Automated update of semver-based git `ref:` values in `roles/*/meta/services.yml`. Upstream tags resolved via `git ls-remote --tags`."
UPDATE_BASE_BRANCH: main
run: bash scripts/github/open_update_pr.sh roles
-3
View File
@@ -1,3 +0,0 @@
# Docker 🐳
Repository-side Docker maintenance: scan role config files for declared image versions and bump them in lockstep with upstream releases.
+3
View File
@@ -0,0 +1,3 @@
# Update 🔄
Repository-side version maintenance: scan role configuration files for declared upstream versions and bump them in lockstep with new releases.
File renamed without changes.
@@ -1,5 +1,5 @@
"""CLI entry point package for Docker image version updates."""
from pathlib import Path
PROJECT_ROOT: Path = Path(__file__).resolve().parents[4]
"""CLI entry point package for Docker image version updates."""
@@ -4,14 +4,14 @@ from __future__ import annotations
import argparse
from pathlib import Path
from utils.docker.version_updater import apply_updates, find_outdated_updates
from utils.update.docker import apply_updates, find_outdated_updates
from . import PROJECT_ROOT
def main() -> int:
parser = argparse.ArgumentParser(
description="Update semver-based Docker image versions in web-*/meta/services.yml."
description="Update semver-based Docker image versions in roles/*/meta/services.yml."
)
parser.add_argument(
"--repo-root",
@@ -0,0 +1,5 @@
"""CLI entry point package for git repository ref updates."""
from pathlib import Path
PROJECT_ROOT: Path = Path(__file__).resolve().parents[4]
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
from utils.update.repository import apply_updates, find_outdated_updates
from . import PROJECT_ROOT
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Update semver-based git `ref:` values in roles/*/meta/services.yml."
)
)
parser.add_argument(
"--repo-root",
type=Path,
default=PROJECT_ROOT,
help="Repository root to scan and update",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print planned updates without modifying files",
)
args = parser.parse_args()
repo_root = args.repo_root.resolve()
updates = find_outdated_updates(repo_root)
if not updates:
print("Repository refs are already up to date.")
return 0
print("Planned repository ref updates:")
for update in updates:
rel_path = update.entry.config_path.relative_to(repo_root)
entity = ".".join(update.entry.entity_path) or "<root>"
print(
f"- {rel_path}:{update.entry.line} {update.entry.role}/{entity} "
f"{update.entry.repository} {update.entry.ref} -> {update.latest}"
)
if args.dry_run:
return 0
changed_paths = apply_updates(updates)
if changed_paths:
print("\nUpdated files:")
for path in changed_paths:
print(f"- {path.relative_to(repo_root)}")
else:
print("\nNo files changed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -15,7 +15,7 @@ GHCR, or other external services. Run them with `make test-external`.
## Location 📁
- External tests MUST live under `tests/external/`.
- You SHOULD group them by dependency family such as `tests/external/docker/`
- You SHOULD group them by dependency family such as `tests/external/update/`
or `tests/external/github/` when more than one test exists.
- Every external test directory MUST contain `__init__.py` so repository-wide
test structure checks keep passing.
@@ -44,7 +44,7 @@ The placement rule is per check. The catalog "Position" column uses these labels
| Rule | Position | Affected test | Effect |
| --------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url` | same or above | [test_urls_reachable.py](../../../../tests/external/repository/test_urls_reachable.py) | Skips probing the literal HTTP(S) URL. Use for CDN roots and API base URLs that 4xx without a path. |
| `docker-version` | line above | [test_image_versions.py](../../../../tests/external/docker/test_image_versions.py) | Skips the live version-update warning for that image's `version:` key. |
| `docker-version` | line above | [test_image_versions.py](../../../../tests/external/update/docker/test_image_versions.py) | Skips the live version-update warning for that image's `version:` key. |
| `direct-yaml` | same line / span | [test_no_direct_calls.py](../../../../tests/lint/repository/yaml/test_no_direct_calls.py) | Allows the marked `yaml.safe_load` / `safe_dump` call to bypass `utils.cache.yaml`. MUST use `nocheck:`, not `noqa:`. |
| `redundant-bool-ternary` | same line | [test_no_redundant_bool_patterns.py](../../../../tests/lint/repository/python/test_no_redundant_bool_patterns.py) | Allows the redundant `\| bool \| ternary('true','false')` pattern when a consumer requires the lowercase tokens. |
| `redundant-bool-comparison` | same line | [test_no_redundant_bool_patterns.py](../../../../tests/lint/repository/python/test_no_redundant_bool_patterns.py) | Allows `... \| bool == True/False/true/false` when a real tri-state guard is intended (distinguishing `None` from `False`). |
@@ -91,7 +91,7 @@ Those images MUST be declared under `defaults/main.yml` and MUST be consumed via
[image_discovery.py](../../../../../utils/docker/image/discovery.py) enumerates all role images from both sources above.
It is used by the mirror pipeline (`cli/contributing/mirror/`) and the external version-check test
([`tests/external/docker/test_image_versions.py`](../../../../../tests/external/docker/test_image_versions.py)).
([`tests/external/update/docker/test_image_versions.py`](../../../../../tests/external/update/docker/test_image_versions.py)).
## When `Dockerfile.j2` Is Acceptable 🤔
+1 -1
View File
@@ -29,7 +29,7 @@ Use this rule when a role needs a Docker image definition.
### Image Versions 🏷️
Use this rule when a role pins container image versions in `roles/web-*/meta/services.yml`.
Use this rule when a role pins container image versions in `roles/*/meta/services.yml`.
- You MUST keep semver-like image versions current when the upstream image publishes matching newer tags.
- You MUST use `# nocheck: docker-version` directly above a `version:` key only for intentional exceptions that should not be flagged by the external image-version check.
+2 -2
View File
@@ -291,7 +291,7 @@ the old `compose.…` or top-level paths.
deep-merge over the assembled payload, just as they used to over
`config/main.yml`.)
- `utils/cache/base.py`
- `utils/docker/version_updater.py`
- `utils/update/docker.py`
- `utils/docker/image/discovery.py`
- `plugins/filter/native_metrics_target.py`
- `plugins/lookup/config.py` (the `config` lookup itself)
@@ -304,7 +304,7 @@ the old `compose.…` or top-level paths.
- `plugins/lookup/nginx.py`
- `plugins/lookup/application_gid.py`
- `plugins/lookup/rbac_group_path.py`
- `cli/contributing/docker/update_versions/__main__.py`
- `cli/contributing/update/docker/__main__.py`
- `cli/administration/deploy/development/inventory.py`
- `cli/meta/roles/applications/resolution/combined/role_introspection.py`
- `utils/roles/applications/services/resolver.py`
+2
View File
@@ -39,6 +39,8 @@ redis:
bookwyrm:
name: bookwyrm
image: python
# TODO: bump when upstream Bookwyrm declares support for Python >= 3.12 in requirements.txt
# nocheck: docker-version
version: 3.11-bookworm
repository: https://github.com/bookwyrm-social/bookwyrm.git
ref: v0.8.5
+3 -3
View File
@@ -1,7 +1,6 @@
# GitHub Workflow Helpers 🔧
This is the SPOT for GitHub workflow helper scripts under `scripts/github/`.
These helpers are called from `.github/workflows/`.
This directory holds the GitHub workflow helper scripts that the workflows under `.github/workflows/` call.
For the repository Make target index, see [make.md](../../docs/contributing/tools/make.md).
## Entry Points 📋
@@ -13,4 +12,5 @@ For the repository Make target index, see [make.md](../../docs/contributing/tool
| `check_git_changes.sh` | Emits a `changed=true/false` workflow output based on `git diff`. | `.github/workflows/update.yml` |
| `install_update_python.sh` | Installs Python dependencies needed by the automated update workflows. | `.github/workflows/update.yml` |
| `open_update_pr.sh` | Commits update changes, pushes the branch, and creates or refreshes the matching Pull Request. | `.github/workflows/update.yml` |
| `update_docker_image_versions.sh` | Runs the Docker image version updater CLI from GitHub Actions. | `.github/workflows/update.yml` |
| `update/image_versions.sh` | Runs the Docker image version updater CLI from GitHub Actions. | `.github/workflows/update.yml` |
| `update/repository_refs.sh` | Runs the git repository ref updater CLI from GitHub Actions. | `.github/workflows/update.yml` |
+44
View File
@@ -0,0 +1,44 @@
# Automated Update Helpers 🔄
This directory contains shell shims that the scheduled `Update: Versions` workflow at [update.yml](../../../.github/workflows/update.yml) invokes once per day to bump pinned upstream versions.
Each script is a thin wrapper around a Python CLI under [cli/contributing/](../../../cli/contributing/).
The CLI does the actual scanning, version comparison, and in-place rewrite of `roles/*/meta/services.yml`; the workflow then checks for a diff and, if any file changed, opens or refreshes a pull request against `main` via [open_update_pr.sh](../open_update_pr.sh).
## Scripts 📄
### `image_versions.sh` 🐳
Bumps semver-based OCI image versions declared as `image:` + `version:` pairs under `roles/*/meta/services.yml`.
Delegates to [docker](../../../cli/contributing/update/docker/), which queries Docker Hub and GHCR for newer tags that share the current tag's depth and flavor.
Tags that carry a `# nocheck: docker-version` marker above their `version:` key are skipped.
### `repository_refs.sh` 🌿
Bumps semver-based git refs declared as `repository:` + `ref:` pairs under `roles/*/meta/services.yml`, at any nesting depth (top-level entity, sub-entity, plugin map).
Delegates to [repository](../../../cli/contributing/update/repository/), which resolves upstream tags via `git ls-remote --tags <repository>`.
Non-semver refs (branches like `master`, `main`, `stable`, commit SHAs) are skipped automatically.
Refs that carry a `# nocheck: repository-version` marker above their `ref:` key are skipped.
## Conventions 📐
Each shim MUST:
- Resolve the repository root relative to its own location so it can run from any working directory.
- Forward every argument it receives to the underlying CLI so `--dry-run` and `--repo-root` keep working from the shell.
- Exit non-zero on CLI failure so the workflow surfaces the error.
Local smoke-test before changing a shim:
```bash
bash scripts/github/update/image_versions.sh --dry-run
bash scripts/github/update/repository_refs.sh --dry-run
```
## Related 🔗
- [README.md](../README.md) for the broader inventory of GitHub workflow helpers.
- [open_update_pr.sh](../open_update_pr.sh) for the shared PR-open/refresh logic both update jobs share.
- [check_git_changes.sh](../check_git_changes.sh) for the diff probe that gates the PR step.
@@ -2,7 +2,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../../.." && pwd)"
cd "${REPO_ROOT}"
python3 -m cli.contributing.docker.update_versions "$@"
python3 -m cli.contributing.update.docker "$@"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../../.." && pwd)"
cd "${REPO_ROOT}"
python3 -m cli.contributing.update.repository "$@"
-251
View File
@@ -1,251 +0,0 @@
"""Check git `ref:` values in `roles/*/meta/services.yml`.
For every entity (at any depth — top-level entity, sub-entity, plugin
map) that declares BOTH `repository:` and `ref:`, and whose `ref:` is
a semver-compatible tag, this test fetches the upstream repository's
git tags via ``git ls-remote --tags <repository>`` and warns when a
newer semver tag exists.
Counterpart to ``tests/external/docker/test_image_versions.py`` —
that one bumps OCI image tags, this one bumps git refs for
from-source builds (plus plugin / build-helper / bootstrap repos).
External test: depends on live ``git ls-remote`` calls against the
remotes declared in services.yml. The test always passes so normal
validation stays stable even when remotes are slow or unreachable;
outdated refs surface as warnings on stdout and as GitHub Actions
``::warning::`` annotations on the offending services.yml line.
Suppress a check by placing ``# nocheck: repository-version`` on the
line directly above the ``ref:`` key (blank lines between are
ignored). Non-semver refs (`master`, `main`, `stable`, commit SHAs,
…) are skipped automatically — only refs that match
``utils.docker.version_updater.is_semver`` are checked.
"""
from __future__ import annotations
import subprocess
import unittest
from typing import TYPE_CHECKING
from utils.annotations.message import warning
from utils.annotations.suppress import is_suppressed_at
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_any
from utils.docker.version_updater import (
is_semver,
latest_semver,
version_depth,
version_flavor,
version_key,
)
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from . import PROJECT_ROOT
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_ROLES_ROOT = PROJECT_ROOT / "roles"
_NOCHECK_MARKER = "repository-version"
def _walk_repo_ref_pairs(node, path: list[str]) -> Iterator[tuple[list[str], str, str]]:
"""Yield ``(entity_path, repository, ref)`` for every dict in the
tree that declares both keys with truthy string values."""
if isinstance(node, dict):
repo = node.get("repository")
ref = node.get("ref")
if isinstance(repo, str) and repo and isinstance(ref, str) and ref:
yield (list(path), repo.strip(), ref.strip())
for key, value in node.items():
if key in ("repository", "ref"):
continue
yield from _walk_repo_ref_pairs(value, [*path, str(key)])
elif isinstance(node, list):
for index, item in enumerate(node):
yield from _walk_repo_ref_pairs(item, [*path, f"[{index}]"])
def _suppressed_ref_paths(config_path: Path) -> set[tuple[int, int]]:
"""Return ``(start_line, indent)`` pairs whose `ref:` line is
annotated with ``# nocheck: repository-version`` on the immediately
preceding line. Paired with ``_locate_ref_lines`` to match entities."""
raw = read_text(str(config_path))
lines = raw.splitlines()
suppressed: set[tuple[int, int]] = set()
for index, line in enumerate(lines):
stripped = line.lstrip()
if not stripped.startswith(("ref:", "ref :")):
continue
if is_suppressed_at(lines, index + 1, _NOCHECK_MARKER, mode="line-above"):
indent = len(line) - len(stripped)
suppressed.add((index + 1, indent))
return suppressed
def _locate_ref_line(config_path: Path, ref_value: str) -> int | None:
"""Return the 1-indexed line number of the `ref: <value>` declaration
in *config_path* whose value matches *ref_value*. Returns ``None``
when no exact match is found (best-effort annotation anchor)."""
raw = read_text(str(config_path))
for index, line in enumerate(raw.splitlines(), start=1):
stripped = line.strip()
if not stripped.startswith(("ref:", "ref :")):
continue
try:
_, value = stripped.split(":", 1)
except ValueError:
continue
value = value.strip().strip("'\"")
if value == ref_value:
return index
return None
def _git_ls_remote_tags(url: str) -> list[str]:
"""Return semver-compatible tag names from ``git ls-remote --tags``.
Stripped of the ``refs/tags/`` prefix and ``^{}`` peeled-tag markers."""
try:
proc = subprocess.run(
["git", "ls-remote", "--tags", url],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
if proc.returncode != 0:
return []
tags: set[str] = set()
for line in proc.stdout.splitlines():
parts = line.split("\t")
if len(parts) != 2:
continue
name = parts[1]
if not name.startswith("refs/tags/"):
continue
name = name[len("refs/tags/") :]
name = name.removesuffix("^{}")
if name:
tags.add(name)
return sorted(tags)
class TestRepositoryVersions(unittest.TestCase):
"""Warn about outdated semver `ref:` values in `meta/services.yml`."""
def test_repository_refs_are_current(self) -> None:
entries: list[dict] = []
for role_dir in sorted(p for p in _ROLES_ROOT.iterdir() if p.is_dir()):
services_path = role_dir / ROLE_FILE_META_SERVICES
if not services_path.is_file():
continue
data = load_yaml_any(str(services_path), default_if_missing=None)
if not isinstance(data, dict):
continue
suppressed = _suppressed_ref_paths(services_path)
for entity_path, repo, ref in _walk_repo_ref_pairs(data, []):
if not is_semver(ref):
continue
line_no = _locate_ref_line(services_path, ref)
if line_no and any(line_no == sl for sl, _ in suppressed):
continue
entries.append(
{
"role": role_dir.name,
"entity": ".".join(entity_path),
"repository": repo,
"ref": ref,
"config_path": str(services_path.relative_to(PROJECT_ROOT)),
"line": line_no,
}
)
# Deduplicate `git ls-remote` calls per repository.
repo_tags: dict[str, list[str]] = {}
for entry in entries:
url = entry["repository"]
if url in repo_tags:
continue
repo_tags[url] = _git_ls_remote_tags(url)
outdated: list[dict] = []
unchecked: list[dict] = []
for entry in entries:
tags = repo_tags.get(entry["repository"], [])
if not tags:
unchecked.append(entry)
continue
latest = latest_semver(
tags,
version_depth(entry["ref"]),
version_flavor(entry["ref"]),
)
if latest and version_key(entry["ref"]) < version_key(latest):
outdated.append({**entry, "latest": latest})
if outdated:
col_w = (28, 30, 50, 12)
header = (
f"{'Role':<{col_w[0]}} {'Entity':<{col_w[1]}} "
f"{'Repository':<{col_w[2]}} {'Current':<{col_w[3]}} Latest"
)
rows = "\n".join(
f"{o['role']:<{col_w[0]}} {o['entity']:<{col_w[1]}} "
f"{o['repository']:<{col_w[2]}} {o['ref']:<{col_w[3]}} {o['latest']}"
for o in outdated
)
print(
f"\n⚠️ Outdated repository refs:\n{header}\n{'-' * 140}\n{rows}\n"
f"\n💡 To suppress a warning add above the ref: key:\n"
f" # nocheck: repository-version"
)
for o in outdated:
msg = (
f"{o['role']}/{o['entity']}: {o['repository']} is at "
f"{o['ref']}, latest semver tag is {o['latest']}"
)
warning(
msg,
title="Outdated repository ref",
file=o["config_path"],
line=o["line"],
)
if unchecked:
col_w = (28, 30, 50)
header = (
f"{'Role':<{col_w[0]}} {'Entity':<{col_w[1]}} "
f"{'Repository':<{col_w[2]}} Current"
)
rows = "\n".join(
f"{o['role']:<{col_w[0]}} {o['entity']:<{col_w[1]}} "
f"{o['repository']:<{col_w[2]}} {o['ref']}"
for o in unchecked
)
print(
f"\n🔍 Unchecked repository refs (remote unreachable or no tags):\n"
f"{header}\n{'-' * 130}\n{rows}"
)
for o in unchecked:
msg = (
f"{o['role']}/{o['entity']}: {o['repository']} ref "
f"{o['ref']} could not be checked (remote unreachable)"
)
warning(
msg,
title="🔍 Unchecked repository ref",
file=o["config_path"],
line=o["line"],
)
# Always pass — outdated refs are warnings, not hard failures.
self.assertIsNotNone(entries)
if __name__ == "__main__":
unittest.main()
File renamed without changes.
+3
View File
@@ -0,0 +1,3 @@
from pathlib import Path
PROJECT_ROOT: Path = Path(__file__).resolve().parents[4]
@@ -1,4 +1,4 @@
"""Check Docker image versions in roles/web-*/meta/services.yml.
"""Check Docker image versions in roles/*/meta/services.yml.
For each service with a semver-compatible version tag the latest available
tag on Docker Hub is fetched and compared. Outdated versions are reported as
@@ -31,19 +31,21 @@ import unittest
from utils.annotations.message import warning
from utils.docker.image.discovery import iter_role_images
from utils.docker.version_updater import (
fetch_dockerhub_tags,
fetch_ghcr_tags,
is_dockerhub,
is_ghcr,
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from utils.update.base import (
is_semver,
latest_semver,
suppressed_services,
version_depth,
version_flavor,
version_key,
)
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from utils.update.docker import (
fetch_dockerhub_tags,
fetch_ghcr_tags,
is_dockerhub,
is_ghcr,
suppressed_services,
)
from . import PROJECT_ROOT
@@ -52,12 +54,9 @@ _ROLES_ROOT = _REPO_ROOT / "roles"
def _collect_entries() -> list[dict]:
"""Collect (role, service, image, version, config_path) for semver versions in web-* roles."""
"""Collect (role, service, image, version, config_path) for semver versions across all roles."""
entries: list[dict] = []
for ref in iter_role_images(_REPO_ROOT):
# Only web-* roles
if not ref.role.startswith("web-"):
continue
# Only semver versions (pure or `<semver>-<flavor>`)
if not is_semver(ref.version):
continue
@@ -108,7 +107,7 @@ def _emit_unchecked_annotation(
class TestDockerImageVersions(unittest.TestCase):
"""Warn about outdated live Docker image versions in roles/web-*/meta/services.yml."""
"""Warn about outdated live Docker image versions in roles/*/meta/services.yml."""
def test_image_versions_are_current(self) -> None:
entries = _collect_entries()
+3
View File
@@ -0,0 +1,3 @@
from pathlib import Path
PROJECT_ROOT: Path = Path(__file__).resolve().parents[4]
@@ -0,0 +1,86 @@
"""Warn about outdated semver git `ref:` values in `roles/*/meta/services.yml`.
For every entity (at any depth — top-level entity, sub-entity, plugin
map) that declares BOTH `repository:` and `ref:`, and whose `ref:` is
a semver-compatible tag, this test fetches the upstream repository's
git tags via ``git ls-remote --tags <repository>`` and warns when a
newer semver tag exists.
Counterpart to ``tests/external/update/docker/test_image_versions.py``
(that one bumps OCI image tags, this one bumps git refs for
from-source builds, plugin and build-helper repos).
The shared discovery / version logic lives in
:mod:`utils.update.repository`; the CI auto-update job at
``.github/workflows/update.yml`` (``update-repository-refs``) consumes
the same module to open PRs against ``main``.
External test: depends on live ``git ls-remote`` calls against the
remotes declared in services.yml. The test always passes so normal
validation stays stable even when remotes are slow or unreachable;
outdated refs surface as warnings on stdout and as GitHub Actions
``::warning::`` annotations on the offending services.yml line.
Suppress a check by placing ``# nocheck: repository-version`` on the
line directly above the ``ref:`` key (blank lines between are
ignored). Non-semver refs (``master``, ``main``, ``stable``, commit
SHAs) are skipped automatically; only refs that match
:func:`utils.update.base.is_semver` are checked.
"""
from __future__ import annotations
import unittest
from utils.annotations.message import warning
from utils.update.repository import (
collect_entries,
find_outdated_updates,
)
from . import PROJECT_ROOT
class TestRepositoryVersions(unittest.TestCase):
"""Warn about outdated semver `ref:` values in `meta/services.yml`."""
def test_repository_refs_are_current(self) -> None:
entries = collect_entries(PROJECT_ROOT)
updates = find_outdated_updates(PROJECT_ROOT)
if updates:
col_w = (28, 30, 50, 12)
header = (
f"{'Role':<{col_w[0]}} {'Entity':<{col_w[1]}} "
f"{'Repository':<{col_w[2]}} {'Current':<{col_w[3]}} Latest"
)
rows = "\n".join(
f"{u.entry.role:<{col_w[0]}} "
f"{'.'.join(u.entry.entity_path) or '<root>':<{col_w[1]}} "
f"{u.entry.repository:<{col_w[2]}} "
f"{u.entry.ref:<{col_w[3]}} {u.latest}"
for u in updates
)
print(
f"\n⚠️ Outdated repository refs:\n{header}\n{'-' * 140}\n{rows}\n"
f"\n💡 To suppress a warning add above the ref: key:\n"
f" # nocheck: repository-version"
)
for u in updates:
entity = ".".join(u.entry.entity_path) or "<root>"
msg = (
f"{u.entry.role}/{entity}: {u.entry.repository} is at "
f"{u.entry.ref}, latest semver tag is {u.latest}"
)
warning(
msg,
title="Outdated repository ref",
file=str(u.entry.config_path.relative_to(PROJECT_ROOT)),
line=u.entry.line,
)
# Always pass — outdated refs are warnings, not hard failures.
self.assertIsNotNone(entries)
if __name__ == "__main__":
unittest.main()
View File
Whitespace-only changes.
@@ -1,21 +1,17 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from utils.cache.files import read_text
from utils.docker.version_updater import (
from utils.update.base import (
is_semver,
latest_semver,
update_config_versions,
version_depth,
version_flavor,
version_key,
)
class TestDockerVersionUpdater(unittest.TestCase):
class TestUpdateBase(unittest.TestCase):
def test_latest_semver_respects_depth(self) -> None:
tags = ["4", "4.5", "4.6", "4.5.1", "5"]
@@ -62,28 +58,6 @@ class TestDockerVersionUpdater(unittest.TestCase):
# Flavor-less current version MUST stay in the unflavored lane.
self.assertEqual(latest_semver(tags, 3, ""), "5.4.7")
def test_update_config_versions_updates_only_target_services(self) -> None:
# Per the file root of meta/services.yml IS the services map
# (no `compose.services` envelope), so service keys are at indent 0.
original = """moodle:
version: "4.5" # Keep comment
image: bitnamilegacy/moodle
nginx:
version: alpine
image: nginx
"""
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "main.yml"
config_path.write_text(original, encoding="utf-8")
changed = update_config_versions(config_path, {"moodle": "5.0"})
self.assertTrue(changed)
updated = read_text(str(config_path))
self.assertIn('version: "5.0" # Keep comment', updated)
self.assertIn("version: alpine", updated)
self.assertNotIn('version: "4.5" # Keep comment', updated)
if __name__ == "__main__":
unittest.main()
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from utils.cache.files import read_text
from utils.update.docker import update_config_versions
class TestUpdateDocker(unittest.TestCase):
def test_update_config_versions_updates_only_target_services(self) -> None:
# The file root of meta/services.yml IS the services map (no
# `compose.services` envelope), so service keys are at indent 0.
original = """moodle:
version: "4.5" # Keep comment
image: bitnamilegacy/moodle
nginx:
version: alpine
image: nginx
"""
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "main.yml"
config_path.write_text(original, encoding="utf-8")
changed = update_config_versions(config_path, {"moodle": "5.0"})
self.assertTrue(changed)
updated = read_text(str(config_path))
self.assertIn('version: "5.0" # Keep comment', updated)
self.assertIn("version: alpine", updated)
self.assertNotIn('version: "4.5" # Keep comment', updated)
if __name__ == "__main__":
unittest.main()
+223
View File
@@ -0,0 +1,223 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from utils.cache.files import read_text
from utils.update.repository import (
RepositoryRefEntry,
RepositoryRefUpdate,
apply_updates,
suppressed_ref_lines,
update_config_refs,
walk_repo_ref_pairs,
)
class TestWalkRepoRefPairs(unittest.TestCase):
def test_finds_top_level_entity(self) -> None:
data = {
"bookwyrm": {
"repository": "https://github.com/bookwyrm-social/bookwyrm.git",
"ref": "v0.8.5",
},
}
pairs = list(walk_repo_ref_pairs(data, ()))
self.assertEqual(
pairs,
[
(
("bookwyrm",),
"https://github.com/bookwyrm-social/bookwyrm.git",
"v0.8.5",
),
],
)
def test_descends_into_nested_plugin_map(self) -> None:
# Discourse-style plugin nesting: discourse.plugins.<name>.repository+ref
data = {
"discourse": {
"plugins": {
"discourse-ldap-auth": {
"repository": "https://github.com/jonmbake/discourse-ldap-auth.git",
"ref": "master",
},
"discourse-prometheus": {
"repository": "https://github.com/discourse/discourse-prometheus.git",
"ref": "v1.0.0",
},
},
},
}
pairs = list(walk_repo_ref_pairs(data, ()))
entity_paths = {pair[0] for pair in pairs}
self.assertIn(("discourse", "plugins", "discourse-ldap-auth"), entity_paths)
self.assertIn(("discourse", "plugins", "discourse-prometheus"), entity_paths)
def test_skips_when_only_repository_or_only_ref(self) -> None:
data = {
"only-repo": {"repository": "https://example.invalid/x.git"},
"only-ref": {"ref": "v1.0.0"},
"both": {
"repository": "https://example.invalid/y.git",
"ref": "v2.0.0",
},
}
pairs = list(walk_repo_ref_pairs(data, ()))
self.assertEqual(len(pairs), 1)
self.assertEqual(pairs[0][0], ("both",))
def test_walks_lists(self) -> None:
data = {
"repos": [
{
"repository": "https://example.invalid/a.git",
"ref": "v1",
},
{
"repository": "https://example.invalid/b.git",
"ref": "v2",
},
],
}
pairs = list(walk_repo_ref_pairs(data, ()))
self.assertEqual(len(pairs), 2)
self.assertEqual(pairs[0][0], ("repos", "[0]"))
self.assertEqual(pairs[1][0], ("repos", "[1]"))
class TestUpdateConfigRefs(unittest.TestCase):
def test_rewrites_target_line_and_preserves_others(self) -> None:
original = """plugin-a:
repository: https://example.invalid/a.git
ref: v1.0.0
plugin-b:
repository: https://example.invalid/b.git
ref: v2.0.0
"""
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "services.yml"
config_path.write_text(original, encoding="utf-8")
# plugin-a's `ref:` is on line 3.
changed = update_config_refs(config_path, {3: "v1.1.0"})
self.assertTrue(changed)
updated = read_text(str(config_path))
self.assertIn(" ref: v1.1.0\n", updated)
self.assertIn(" ref: v2.0.0\n", updated)
def test_preserves_quoting_and_trailing_comment(self) -> None:
original = ' ref: "v0.7.5" # pin\n'
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "services.yml"
config_path.write_text(original, encoding="utf-8")
changed = update_config_refs(config_path, {1: "v0.8.5"})
self.assertTrue(changed)
self.assertEqual(
read_text(str(config_path)),
' ref: "v0.8.5" # pin\n',
)
def test_no_op_when_target_value_already_matches(self) -> None:
original = " ref: v1.0.0\n"
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "services.yml"
config_path.write_text(original, encoding="utf-8")
changed = update_config_refs(config_path, {1: "v1.0.0"})
self.assertFalse(changed)
self.assertEqual(read_text(str(config_path)), original)
def test_skips_out_of_range_line_numbers(self) -> None:
original = " ref: v1.0.0\n"
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "services.yml"
config_path.write_text(original, encoding="utf-8")
changed = update_config_refs(config_path, {99: "v2.0.0"})
self.assertFalse(changed)
self.assertEqual(read_text(str(config_path)), original)
class TestSuppressedRefLines(unittest.TestCase):
def test_recognises_line_above_marker(self) -> None:
content = """plugin-a:
repository: https://example.invalid/a.git
# nocheck: repository-version
ref: v1.0.0
plugin-b:
repository: https://example.invalid/b.git
ref: v2.0.0
"""
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "services.yml"
config_path.write_text(content, encoding="utf-8")
suppressed = suppressed_ref_lines(config_path)
# plugin-a's `ref:` sits on line 4 and is suppressed.
self.assertEqual(suppressed, {4})
class TestApplyUpdates(unittest.TestCase):
def test_groups_updates_per_config_file(self) -> None:
file_a_content = " ref: v1.0.0\n"
file_b_content = " ref: v2.0.0\n"
with tempfile.TemporaryDirectory() as tmpdir:
config_a = Path(tmpdir) / "a" / "services.yml"
config_b = Path(tmpdir) / "b" / "services.yml"
config_a.parent.mkdir()
config_b.parent.mkdir()
config_a.write_text(file_a_content, encoding="utf-8")
config_b.write_text(file_b_content, encoding="utf-8")
updates = [
RepositoryRefUpdate(
entry=RepositoryRefEntry(
role="role-a",
entity_path=("plugin-a",),
repository="https://example.invalid/a.git",
ref="v1.0.0",
config_path=config_a,
line=1,
),
latest="v1.1.0",
),
RepositoryRefUpdate(
entry=RepositoryRefEntry(
role="role-b",
entity_path=("plugin-b",),
repository="https://example.invalid/b.git",
ref="v2.0.0",
config_path=config_b,
line=1,
),
latest="v2.1.0",
),
]
changed = apply_updates(updates)
self.assertEqual(set(changed), {config_a, config_b})
self.assertEqual(read_text(str(config_a)), " ref: v1.1.0\n")
self.assertEqual(read_text(str(config_b)), " ref: v2.1.0\n")
if __name__ == "__main__":
unittest.main()
View File
Whitespace-only changes.
+65
View File
@@ -0,0 +1,65 @@
"""Semver primitives shared by every version-bump backend.
A "version" here is a tag of the shape ``v?<numeric-semver>(-<flavor>)?``,
where the numeric part has 1 to 4 dot-separated components and the
optional ``-<flavor>`` suffix is an opaque discriminator (e.g. the
Docker Official Image tag ``5.4.5-php8.3-apache`` or the npm-style
``1.2.3-alpha``).
Upgrade candidates MUST share the same depth (1, 2, 3 or 4 components)
AND the same flavor as the current tag, so that ``5.4.5-php8.3-apache``
never silently bumps to ``5.4.6-php8.4-apache`` (different runtime) or
to ``5.4.6`` (different depth).
"""
from __future__ import annotations
import re
_SEMVER_CORE = r"v?\d+(?:\.\d+){0,3}"
_VERSIONED_TAG_RE = re.compile(rf"^(?P<semver>{_SEMVER_CORE})(?P<flavor>-\S+)?$")
def _parse_versioned_tag(tag: str) -> tuple[str, str] | None:
match = _VERSIONED_TAG_RE.match(str(tag).strip())
if match is None:
return None
return match.group("semver"), match.group("flavor") or ""
def is_semver(value: str) -> bool:
return _parse_versioned_tag(value) is not None
def version_key(tag: str) -> tuple[int, ...]:
parsed = _parse_versioned_tag(tag)
if parsed is None:
return (0,) * 4
semver, _flavor = parsed
parts = tuple(int(part) for part in semver.lstrip("v").split("."))
return parts + (0,) * (4 - len(parts))
def version_depth(tag: str) -> int:
parsed = _parse_versioned_tag(tag)
if parsed is None:
return 0
semver, _flavor = parsed
return len(semver.lstrip("v").split("."))
def version_flavor(tag: str) -> str:
"""Return the ``-<flavor>`` suffix of a versioned tag, or "" when none."""
parsed = _parse_versioned_tag(tag)
return parsed[1] if parsed else ""
def latest_semver(tags: list[str], depth: int, flavor: str = "") -> str | None:
candidates = [
tag
for tag in tags
if is_semver(tag)
and version_depth(tag) == depth
and version_flavor(tag) == flavor
]
return max(candidates, key=version_key, default=None)
@@ -1,3 +1,15 @@
"""Detect and rewrite outdated Docker image `version:` tags in
`roles/*/meta/services.yml`.
Counterpart to :mod:`utils.update.repository` (which handles git refs).
Both share the semver primitives in :mod:`utils.update.base`; this
module owns the Docker-Hub / GHCR registry lookups and the YAML
walker that rewrites the matching ``version:`` line.
Suppress a check by placing ``# nocheck: docker-version`` on the line
directly above the ``version:`` key.
"""
from __future__ import annotations
import json
@@ -20,19 +32,22 @@ from utils.docker.image.ref import (
split_registry_and_name,
)
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from utils.update.base import (
is_semver,
latest_semver,
version_depth,
version_flavor,
version_key,
)
if TYPE_CHECKING:
from pathlib import Path
_SEMVER_CORE = r"v?\d+(?:\.\d+){0,3}"
# Tags that extend a semver with a `-<flavor>` suffix, e.g. the Docker
# Official image tag `5.4.5-php8.3-apache`. The flavor is treated as an
# opaque discriminator: tags are only considered upgrade candidates for
# each other when their flavor strings match.
_VERSIONED_TAG_RE = re.compile(rf"^(?P<semver>{_SEMVER_CORE})(?P<flavor>-\S+)?$")
_KEY_RE = re.compile(r"^(?P<indent>\s*)(?P<key>[A-Za-z0-9_-]+):(?P<rest>.*)$")
_VERSION_VALUE_RE = re.compile(
r"^(?P<prefix>\s*version\s*:\s*)(?P<quote>[\"']?)(?P<value>[^\"'#\s]+)(?P=quote)(?P<suffix>\s*(?:#.*)?)$"
r"^(?P<prefix>\s*version\s*:\s*)"
r"(?P<quote>[\"']?)(?P<value>[^\"'#\s]+)(?P=quote)"
r"(?P<suffix>\s*(?:#.*)?)$"
)
@@ -51,57 +66,6 @@ class DockerImageVersionUpdate:
latest: str
def _parse_versioned_tag(tag: str) -> tuple[str, str] | None:
match = _VERSIONED_TAG_RE.match(str(tag).strip())
if match is None:
return None
return match.group("semver"), match.group("flavor") or ""
def is_semver(value: str) -> bool:
return _parse_versioned_tag(value) is not None
def version_key(tag: str) -> tuple[int, ...]:
parsed = _parse_versioned_tag(tag)
if parsed is None:
return (0,) * 4
semver, _flavor = parsed
parts = tuple(int(part) for part in semver.lstrip("v").split("."))
return parts + (0,) * (4 - len(parts))
def version_depth(tag: str) -> int:
parsed = _parse_versioned_tag(tag)
if parsed is None:
return 0
semver, _flavor = parsed
return len(semver.lstrip("v").split("."))
def version_flavor(tag: str) -> str:
"""Return the `-<flavor>` suffix of a versioned tag, or "" when none.
Only tags that share the same flavor are considered upgrade candidates
for one another, so that e.g. `5.4.5-php8.3-apache` is never
auto-bumped to `5.4.6-php8.4-apache` (different runtime flavor) or to
`5.4.6-php8.3-fpm` (different webserver flavor).
"""
parsed = _parse_versioned_tag(tag)
return parsed[1] if parsed else ""
def latest_semver(tags: list[str], depth: int, flavor: str = "") -> str | None:
candidates = [
tag
for tag in tags
if is_semver(tag)
and version_depth(tag) == depth
and version_flavor(tag) == flavor
]
return max(candidates, key=version_key, default=None)
def is_dockerhub(image: str) -> bool:
parsed = split_registry_and_name(image)
if parsed is None:
@@ -194,11 +158,11 @@ def fetch_ghcr_tags(image: str) -> list[str]:
def suppressed_services(config_path: Path) -> set[str]:
"""Return service names whose `version:` line is annotated with the
unified ``# nocheck: docker-version`` marker.
"""Return service names whose ``version:`` line is annotated with
the unified ``# nocheck: docker-version`` marker.
The file root of `meta/services.yml` IS the services map. There
is no `services.` wrapper to walk into.
The file root of ``meta/services.yml`` IS the services map; there
is no ``services.`` wrapper to walk into.
"""
raw = read_text(str(config_path))
lines = raw.splitlines()
@@ -235,8 +199,6 @@ def collect_entries(repo_root: Path) -> list[DockerImageVersionEntry]:
entries: list[DockerImageVersionEntry] = []
for ref in iter_role_images(repo_root):
if not ref.role.startswith("web-"):
continue
if ref.source_file != ROLE_FILE_META_SERVICES:
continue
if not is_semver(ref.version):
@@ -293,13 +255,13 @@ def find_outdated_updates(repo_root: Path) -> list[DockerImageVersionUpdate]:
def update_config_versions(config_path: Path, service_versions: dict[str, str]) -> bool:
"""Rewrite each ``<service>.version:`` value in `meta/services.yml`.
"""Rewrite each ``<service>.version:`` value in ``meta/services.yml``.
The file root IS the services map (no `services.`
wrapper), so the walker tracks one nesting level: top-level service keys
and their immediate ``version:`` field.
The file root IS the services map (no ``services.`` wrapper), so the
walker tracks one nesting level: top-level service keys and their
immediate ``version:`` field.
"""
lines = config_path.read_text( # nocheck: cache-read — function reads then rewrites config_path; cached value would go stale on subsequent calls
lines = config_path.read_text( # nocheck: cache-read — read-then-write of the same file; cached read would go stale
encoding="utf-8"
).splitlines(keepends=True)
changed = False
+263
View File
@@ -0,0 +1,263 @@
"""Detect and rewrite outdated git `ref:` values in `roles/*/meta/services.yml`.
Counterpart to :mod:`utils.update.docker` (which bumps OCI image
`version:` tags). Both share the semver primitives in
:mod:`utils.update.base`; this module owns the recursive walker over
the services map and the upstream-tag lookup via
``git ls-remote --tags <repository>``.
Recursive walker: every dict at any depth (top-level entity,
sub-entity, plugin map, ...) that declares BOTH `repository:` and
`ref:` is a candidate. Only semver-compatible refs are
version-checkable; branches (`master`, `main`, `stable`) and commit
SHAs are skipped.
Each unique repository URL is queried once per run; the result is
reused for every entry pointing at the same repo.
Suppress a check by placing ``# nocheck: repository-version`` on the
line directly above the `ref:` key.
"""
from __future__ import annotations
import re
import subprocess
from dataclasses import dataclass
from typing import TYPE_CHECKING
from utils.annotations.suppress import is_suppressed_at
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from utils.update.base import (
is_semver,
latest_semver,
version_depth,
version_flavor,
version_key,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_NOCHECK_MARKER = "repository-version"
_REF_VALUE_RE = re.compile(
r"^(?P<prefix>\s*ref\s*:\s*)"
r"(?P<quote>[\"']?)(?P<value>[^\"'#\s]+)(?P=quote)"
r"(?P<suffix>\s*(?:#.*)?)$"
)
@dataclass(frozen=True)
class RepositoryRefEntry:
role: str
entity_path: tuple[str, ...]
repository: str
ref: str
config_path: Path
line: int
@dataclass(frozen=True)
class RepositoryRefUpdate:
entry: RepositoryRefEntry
latest: str
def walk_repo_ref_pairs(
node, path: tuple[str, ...]
) -> Iterator[tuple[tuple[str, ...], str, str]]:
"""Yield ``(entity_path, repository, ref)`` for every dict in the
tree that declares both keys with truthy string values."""
if isinstance(node, dict):
repo = node.get("repository")
ref = node.get("ref")
if isinstance(repo, str) and repo and isinstance(ref, str) and ref:
yield (path, repo.strip(), ref.strip())
for key, value in node.items():
if key in ("repository", "ref"):
continue
yield from walk_repo_ref_pairs(value, (*path, str(key)))
elif isinstance(node, list):
for index, item in enumerate(node):
yield from walk_repo_ref_pairs(item, (*path, f"[{index}]"))
def git_ls_remote_tags(url: str) -> list[str]:
"""Return tag names from ``git ls-remote --tags``, stripped of the
``refs/tags/`` prefix and the ``^{}`` peeled-tag marker."""
try:
proc = subprocess.run(
["git", "ls-remote", "--tags", url],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
if proc.returncode != 0:
return []
tags: set[str] = set()
for line in proc.stdout.splitlines():
parts = line.split("\t")
if len(parts) != 2:
continue
name = parts[1]
if not name.startswith("refs/tags/"):
continue
name = name[len("refs/tags/") :]
name = name.removesuffix("^{}")
if name:
tags.add(name)
return sorted(tags)
def _ref_lines(lines: list[str]) -> list[tuple[int, str]]:
"""Return ``(1-indexed line number, ref value)`` for every `ref:`
declaration in *lines*."""
out: list[tuple[int, str]] = []
for index, line in enumerate(lines, start=1):
stripped = line.strip()
if not stripped.startswith(("ref:", "ref :")):
continue
try:
_, value = stripped.split(":", 1)
except ValueError:
continue
value = value.split("#", 1)[0].strip().strip("'\"")
if value:
out.append((index, value))
return out
def suppressed_ref_lines(config_path: Path) -> set[int]:
"""Return 1-indexed line numbers of `ref:` declarations that carry
a ``# nocheck: repository-version`` marker on the preceding line."""
raw = read_text(str(config_path))
lines = raw.splitlines()
suppressed: set[int] = set()
for line_no, _ref_value in _ref_lines(lines):
if is_suppressed_at(lines, line_no, _NOCHECK_MARKER, mode="line-above"):
suppressed.add(line_no)
return suppressed
def collect_entries(repo_root: Path) -> list[RepositoryRefEntry]:
"""Return every semver-checkable ``(repository, ref)`` declaration
under ``roles/*/meta/services.yml`` whose ref is not suppressed."""
roles_root = repo_root / "roles"
entries: list[RepositoryRefEntry] = []
for role_dir in sorted(p for p in roles_root.iterdir() if p.is_dir()):
services_path = role_dir / ROLE_FILE_META_SERVICES
if not services_path.is_file():
continue
data = load_yaml_any(str(services_path), default_if_missing=None)
if not isinstance(data, dict):
continue
raw = read_text(str(services_path))
# Sequential ref→line cursor: when the same ref value appears
# at multiple lines, each candidate consumes the next match so
# nested entries with identical refs each get their own line.
ref_line_index: dict[str, list[int]] = {}
for line_no, ref_value in _ref_lines(raw.splitlines()):
ref_line_index.setdefault(ref_value, []).append(line_no)
suppressed = suppressed_ref_lines(services_path)
for entity_path, repo, ref in walk_repo_ref_pairs(data, ()):
if not is_semver(ref):
continue
candidates = ref_line_index.get(ref) or []
if not candidates:
continue
line_no = candidates.pop(0)
if line_no in suppressed:
continue
entries.append(
RepositoryRefEntry(
role=role_dir.name,
entity_path=entity_path,
repository=repo,
ref=ref,
config_path=services_path,
line=line_no,
)
)
return entries
def find_outdated_updates(repo_root: Path) -> list[RepositoryRefUpdate]:
"""Cross-reference every collected entry against ``git ls-remote
--tags`` and return the entries whose ref is older than the latest
semver tag with the same depth and flavor."""
entries = collect_entries(repo_root)
repo_tags: dict[str, list[str]] = {}
for entry in entries:
if entry.repository in repo_tags:
continue
repo_tags[entry.repository] = git_ls_remote_tags(entry.repository)
updates: list[RepositoryRefUpdate] = []
for entry in entries:
tags = repo_tags.get(entry.repository, [])
if not tags:
continue
latest = latest_semver(
tags,
version_depth(entry.ref),
version_flavor(entry.ref),
)
if latest and version_key(entry.ref) < version_key(latest):
updates.append(RepositoryRefUpdate(entry=entry, latest=latest))
return updates
def update_config_refs(config_path: Path, line_to_new_ref: dict[int, str]) -> bool:
"""Rewrite specific ``ref:`` lines in *config_path*. *line_to_new_ref*
maps the 1-indexed line number to the desired new value."""
text = config_path.read_text( # nocheck: cache-read — read-then-write of the same file; cached read would go stale
encoding="utf-8"
)
lines = text.splitlines(keepends=True)
changed = False
for line_no, new_ref in line_to_new_ref.items():
idx = line_no - 1
if idx < 0 or idx >= len(lines):
continue
match = _REF_VALUE_RE.match(lines[idx].rstrip("\n"))
if match is None:
continue
if match.group("value") == new_ref:
continue
quote = match.group("quote")
rebuilt = (
f"{match.group('prefix')}{quote}{new_ref}{quote}{match.group('suffix')}"
)
if lines[idx].endswith("\n"):
rebuilt += "\n"
lines[idx] = rebuilt
changed = True
if changed:
config_path.write_text("".join(lines), encoding="utf-8")
return changed
def apply_updates(updates: list[RepositoryRefUpdate]) -> list[Path]:
"""Apply every update in *updates* to disk. Returns the list of
config files that were actually modified."""
grouped: dict[Path, dict[int, str]] = {}
for update in updates:
grouped.setdefault(update.entry.config_path, {})[update.entry.line] = (
update.latest
)
changed_paths: list[Path] = []
for config_path, line_to_new_ref in sorted(grouped.items()):
if update_config_refs(config_path, line_to_new_ref):
changed_paths.append(config_path)
return changed_paths