Files
computer-playbook/utils/github/variant_bundles.py
T
kevinveenbirkenbachandClaude Opus 4.8 35c2ff5f26 feat(deploy): runner-split variant bundles + unified variant selector
Thread a single `variant` selector (CSV) end-to-end so CI can split a
role's matrix deploy across runners.

- variant_select.py (SPOT, shared by init + deploy): one --variant taking
  a CSV (2 pins one round, 0,1,2 is a runner bundle, empty = full matrix);
  replaces the old separate --variant/--variants.
- planner.filter_plan_to_variants filters the plan to a round subset.
- CI discovery (utils/github/variant_bundles.py) splits roles with more
  than INFINITO_VARIANT_BUNDLE_SIZE (default 3) variants into bundles of
  consecutive indices, one matrix runner each (5 -> 0,1,2 + 3,4), threaded
  via the variant env. test-deploy-{local,universal,workstation,server}.yml
  switch to matrix.include with a comma-free variant_slug for artifact/job
  names (GitHub expressions have no replace()).
- runtime_budget honours INFINITO_VARIANT_TIME_OVERFLOW (cut|fail) when a
  runner cannot finish its slice within INFINITO_MAX_RUNTIME.

Unit tests + docs (variants.md, deploy.md) included.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:52:50 +02:00

104 lines
3.4 KiB
Python

"""Expand a discovered app-id list into CI deploy-matrix entries.
A role whose matrix-deploy declares more variants than a single runner should
iterate (``INFINITO_VARIANT_BUNDLE_SIZE``, default 3) is split into bundles of
consecutive variant indices, one runner per bundle — e.g. a 5-variant role
becomes ``0,1,2`` and ``3,4``. Roles that fit one runner stay a single entry.
Each entry is ``{"apps": <id>, "variant": "<csv>", "variant_slug": "<dashed>"}``;
an empty ``variant`` means full-matrix mode. The ``variant`` slice is threaded
through to ``cli.administration.deploy.development`` via the ``variant``
environment variable (consumed by ``--variant``), so a runner only iterates the
rounds in its bundle. ``variant_slug`` is a comma-free copy for artifact/job
names (GitHub Actions expressions have no string-replace function).
"""
from __future__ import annotations
import json
import os
import sys
from typing import TYPE_CHECKING
from utils.cache.applications import get_variants
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping, Sequence
from typing import Any
DEFAULT_BUNDLE_SIZE = 3
def resolve_bundle_size(raw: str | None = None) -> int:
if raw is None:
raw = os.environ.get("INFINITO_VARIANT_BUNDLE_SIZE")
value = (raw or "").strip()
if not value:
return DEFAULT_BUNDLE_SIZE
try:
size = int(value)
except ValueError:
raise ValueError(
f"INFINITO_VARIANT_BUNDLE_SIZE must be an integer, got {value!r}"
) from None
if size < 1:
raise ValueError("INFINITO_VARIANT_BUNDLE_SIZE must be >= 1")
return size
def chunk_indices(count: int, bundle_size: int) -> list[list[int]]:
return [
list(range(start, min(start + bundle_size, count)))
for start in range(0, count, bundle_size)
]
def variant_count(variants_per_app: Mapping[str, Sequence[Any]], app: str) -> int:
return max(1, len(variants_per_app.get(app) or []))
def _entry(app: str, variant_csv: str) -> dict[str, str]:
# `variant_slug` is a comma-free copy for artifact/job names: GitHub Actions
# expressions have no replace(), so the CSV cannot be sanitised in YAML.
return {
"apps": app,
"variant": variant_csv,
"variant_slug": variant_csv.replace(",", "-"),
}
def expand_apps(
apps: Iterable[str],
variants_per_app: Mapping[str, Sequence[Any]],
bundle_size: int = DEFAULT_BUNDLE_SIZE,
) -> list[dict[str, str]]:
entries: list[dict[str, str]] = []
for app in apps:
count = variant_count(variants_per_app, app)
if count <= bundle_size:
entries.append({"apps": app, "variant": "", "variant_slug": ""})
continue
entries.extend(
_entry(app, ",".join(map(str, chunk)))
for chunk in chunk_indices(count, bundle_size)
)
return entries
def main(argv: Sequence[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
raw = argv[0] if argv else sys.stdin.read()
apps = json.loads(raw) if raw.strip() else []
if not isinstance(apps, list):
raise SystemExit(
f"variant_bundles: expected a JSON array of app ids, got "
f"{type(apps).__name__}"
)
entries = expand_apps(apps, get_variants(), resolve_bundle_size())
print(json.dumps(entries))
return 0
if __name__ == "__main__":
raise SystemExit(main())