fix(ci): narrow a row's architectures by what its variant deploys

row_architectures intersected the run's pool with the declarations of
every role in the complexity closure. That closure follows every
conditional provider flag through the base graph, so tor -> web-svc-mirror
-> prometheus -> openwebui -> litellm -> lmstudio put the amd64-only
lmstudio into the closure of 93 of 128 roles, including the minimal
variants that never deploy it. A single amd64 pin would have withdrawn
arm64 from nearly the whole matrix.

The matrix now replaces a row's services with the include set that
plan_dev_inventory_matrix, the planner the deploy step itself uses,
resolves for that variant. Against CI run 35674334334 it matches every
data point: lmstudio for zammad#2, moodle#2 and xwiki#2, which pulled it
on arm64, and not for docs#0 or legal#0, which did not. With the pins of
the next commit, 126 of 128 roles keep arm64 rows instead of 34.

Without any architecture declaration the emitted matrix is byte-identical
to before under a fixed discovery seed. The planner adds about 37 s per
matrix build, and a role whose plan errors now stops the matrix instead of
only its own deploy job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-22 16:01:09 +02:00
1 parent fa7eba3771
commit c7fb6e60c5
5 files changed
+63 -10

No files matched your search

+1 -1
View File
@@ -204,7 +204,7 @@ entity, next to `modes`:
```
The row then draws from that intersection instead of the run's whole pool, and
so does every row whose transitive service closure contains that role. A
so does every row whose variant deploys that role, as the deploy planner resolves it. A
role and a run that permit nothing in common abort the matrix rather than
dropping the row.
+32 -6
View File
@@ -29,12 +29,17 @@ passing state between them.
from __future__ import annotations
import argparse
import functools
import json
import os
import sys
from cli.administration.deploy.development.inventory.planner import (
plan_dev_inventory_matrix,
)
from cli.meta.ci import chunks, query, slots
from utils.cache.applications import get_variants
from utils.cache.files import PROJECT_ROOT
from utils.github.variant import axes, instructions, pools, selection, tor
from utils.roles.display import display_names
@@ -81,6 +86,24 @@ def candidates(
return [{**row, "modes": query.row_modes(row, modes)} for row in rows]
@functools.cache
def deployed_rounds(app: str) -> tuple[tuple[str, ...], ...]:
"""The apps each variant round of *app* deploys."""
return tuple(
include
for _index, _dir, _variants, include, _purge in plan_dev_inventory_matrix(
roles_dir=str(PROJECT_ROOT / "roles"),
primary_apps=[app],
base_inventory_dir="plan",
)
)
def with_deployed_services(row: dict) -> dict:
"""*row* with ``services`` narrowed to what its variant deploys."""
return {**row, "services": deployed_rounds(row["name"])[row.get("variant") or 0]}
def entries_of(
*,
modes: tuple[str, ...],
@@ -95,12 +118,15 @@ def entries_of(
) -> list[dict[str, str]]:
"""Every candidate row of the sweep, axes assigned, in global order."""
return axes.assign(
candidates(
modes=modes,
whitelist=whitelist,
priority=priority,
lifecycles=lifecycles,
),
[
with_deployed_services(row)
for row in candidates(
modes=modes,
whitelist=whitelist,
priority=priority,
lifecycles=lifecycles,
)
],
sweep=sweep,
tor_mode=tor_mode,
distros=distros,
@@ -58,8 +58,8 @@ machine, not just a value the job applies to itself.
.architectures` lists what the role can run on, next to `modes`, because both
state a capability rather than a testing preference. The row draws from the
intersection of the run's pool with the declarations of the role and of every
service in its transitive closure, because the deploy pulls those in too; an
empty intersection aborts the matrix.
role the deploy planner brings up for that variant, because the deploy pulls
those in too; an empty intersection aborts the matrix.
- **CI images are built natively per architecture and published as one tag per
distro.** Each architecture builds on its own runner, and
@@ -192,5 +192,32 @@ class TestCandidates(unittest.TestCase):
self.assertEqual(discovered.call_args.kwargs["blacklist"], "web-app-b")
class TestWithDeployedServices(unittest.TestCase):
def setUp(self) -> None:
matrix.deployed_rounds.cache_clear()
self.addCleanup(matrix.deployed_rounds.cache_clear)
def test_each_variant_takes_the_apps_its_own_round_deploys(self) -> None:
plan = [
(0, "plan-0", {}, ("web-app-a", "svc-db-postgres"), ()),
(1, "plan-1", {}, ("web-app-a", "svc-ai-lmstudio"), ()),
]
with mock.patch.object(
matrix, "plan_dev_inventory_matrix", return_value=plan
) as planner:
rows = [
matrix.with_deployed_services(
{"name": "web-app-a", "variant": variant, "services": ["x"]}
)
for variant in (0, 1)
]
self.assertEqual(
[row["services"] for row in rows],
[("web-app-a", "svc-db-postgres"), ("web-app-a", "svc-ai-lmstudio")],
)
planner.assert_called_once()
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -209,7 +209,7 @@ def row_architectures(
Args:
app: role id, read for its ``meta/services.yml`` declaration.
run_pool: what the run permits, already narrowed by its own input.
services: the row's transitive service closure, each narrowing the
services: the roles the row's variant deploys, each narrowing the
row like the role's own declaration.
Raises: