mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 12:17:13 +00:00
feat(ci): spread the deploy matrix over amd64 and arm64
Both architectures were claimed to work and only one was ever exercised. The deploy matrix now carries an architecture the way it carries mode, tor, distro and filesystem, so every run proves both. A job cannot ask for an architecture the way it asks for a distro, because the architecture is the machine rather than something the job installs on it. The matrix therefore carries a runner label and `runs-on` reads it, and an architecture without a label in RUNNERS is a build error rather than a row that lands on the wrong hardware and still reports green. assert_architecture.sh checks the runner it actually got, because a label that stops resolving to arm64 would otherwise go unnoticed forever. Assignment is rotation, not chance: two architectures against five distros and fifteen distro/filesystem positions are coprime enough that every combination comes round. A role narrows the pool through `architectures` in its meta/services.yml; without one it takes whatever the rotation hands it. An empty intersection aborts instead of silently removing the role from the run. Images are built per architecture and pushed under an architecture-suffixed tag, then merged into the plain tag by manifest_all.sh; pushing the plain tag from both builds would let the last one win and send half the rows to the wrong platform. cleanup.sh now protects the `ci-<sha>-<architecture>` children of a protected tag, because the plain tag is an index over them and deleting a child leaves it unpullable on that platform. label.py is split out of axes.py, which the 500-line limit no longer fits. Writing a job title and reading one back belong together: a retrigger that recovers an axis wrongly replays a different deploy than the one that failed, and a drifting artifact name makes the failure reporter download nothing. The leading `.*` in LABEL_RE is greedy on purpose so it anchors on the last mode glyph; a reusable-workflow caller path carries a glyph of its own, and test_selection.py guards the case. Verified: 313 unit tests and 48 subtests across utils/github/variant, utils/roles and cli/meta/ci. Locally, a deploy pinned to arm64 reaches ok=1444 and its image builds log aarch64-linux-gnu paths. Not verified here: actionlint over the seven changed workflows, and a full `make test`. The matrix doubles the row count, so runtime and runner availability change for every CI run from here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c9b3f84b48
commit
2a7455bd90
38 files changed
+1055
-200
No files matched your search
@@ -25,6 +25,7 @@ def _assign(rows, **kwargs) -> list[dict[str, str]]:
|
||||
unnarrowed run hands it."""
|
||||
kwargs.setdefault("distros", axes.DISTROS)
|
||||
kwargs.setdefault("filesystems", axes.FILESYSTEMS)
|
||||
kwargs.setdefault("architectures", axes.ARCHITECTURES)
|
||||
return axes.assign(rows, **kwargs)
|
||||
|
||||
|
||||
@@ -111,6 +112,7 @@ class TestArtifactSlug(unittest.TestCase):
|
||||
entry["tor"] == "true",
|
||||
entry["distro"],
|
||||
entry["filesystem"],
|
||||
entry["architecture"],
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -473,6 +475,7 @@ class TestDistroAndFilesystemAxes(unittest.TestCase):
|
||||
tor_mode="disabled",
|
||||
distros=("debian",),
|
||||
filesystems=("zfs",),
|
||||
architectures=("amd64",),
|
||||
variants_per_app=_VARIANTS,
|
||||
)
|
||||
self.assertEqual(len(entries), 1)
|
||||
@@ -615,5 +618,109 @@ class TestEnvironmentReads(unittest.TestCase):
|
||||
self.assertEqual(tor.resolve_tor_mode(), "exclusive")
|
||||
|
||||
|
||||
class TestArchitectureAxis(unittest.TestCase):
|
||||
"""The architecture rotates like the other axes and narrows per role."""
|
||||
|
||||
def test_consecutive_rows_of_one_role_alternate_architectures(self) -> None:
|
||||
rows = [_row("web-app-b", index, ("compose",)) for index in range(6)]
|
||||
entries = _assign(
|
||||
rows, sweep=0, tor_mode="disabled", variants_per_app=_VARIANTS
|
||||
)
|
||||
drawn = [entry["architecture"] for entry in entries]
|
||||
self.assertEqual(set(drawn), set(pools.ARCHITECTURES))
|
||||
self.assertEqual(
|
||||
drawn.count("amd64"),
|
||||
drawn.count("arm64"),
|
||||
f"six rows must split evenly over both architectures; got {drawn}. "
|
||||
f"An uneven split means the rotation is keyed on something that "
|
||||
f"does not advance per row, and a role with few rows would then "
|
||||
f"never reach one of the two.",
|
||||
)
|
||||
|
||||
def test_the_architecture_pairs_with_every_distro_over_a_sweep(self) -> None:
|
||||
"""Two architectures against five distros only cover all ten pairs
|
||||
because the pool sizes are coprime; a pool that stops being coprime
|
||||
would silently test half the combinations."""
|
||||
rows = [_row("web-app-b", index, ("compose",)) for index in range(10)]
|
||||
entries = _assign(
|
||||
rows, sweep=0, tor_mode="disabled", variants_per_app=_VARIANTS
|
||||
)
|
||||
pairs = {(entry["distro"], entry["architecture"]) for entry in entries}
|
||||
self.assertEqual(len(pairs), len(pools.DISTROS) * len(pools.ARCHITECTURES))
|
||||
|
||||
def test_the_runner_label_follows_the_architecture(self) -> None:
|
||||
rows = [_row("web-app-b", index, ("compose",)) for index in range(2)]
|
||||
entries = _assign(
|
||||
rows, sweep=0, tor_mode="disabled", variants_per_app=_VARIANTS
|
||||
)
|
||||
for entry in entries:
|
||||
with self.subTest(entry["architecture"]):
|
||||
self.assertEqual(entry["runner"], pools.RUNNERS[entry["architecture"]])
|
||||
|
||||
def test_a_role_declaring_one_architecture_never_draws_the_other(self) -> None:
|
||||
rows = [_row("web-app-b", index, ("compose",)) for index in range(4)]
|
||||
with mock.patch.object(axes, "get_role_architectures", return_value=["arm64"]):
|
||||
entries = _assign(
|
||||
rows, sweep=0, tor_mode="disabled", variants_per_app=_VARIANTS
|
||||
)
|
||||
self.assertEqual({entry["architecture"] for entry in entries}, {"arm64"})
|
||||
|
||||
def test_a_role_and_run_that_permit_nothing_in_common_abort(self) -> None:
|
||||
rows = [_row("web-app-b", 0, ("compose",))]
|
||||
with (
|
||||
mock.patch.object(axes, "get_role_architectures", return_value=["arm64"]),
|
||||
self.assertRaises(SystemExit),
|
||||
):
|
||||
_assign(
|
||||
rows,
|
||||
sweep=0,
|
||||
tor_mode="disabled",
|
||||
architectures=("amd64",),
|
||||
variants_per_app=_VARIANTS,
|
||||
)
|
||||
|
||||
def test_a_pinned_architecture_replaces_the_rotation(self) -> None:
|
||||
rows = [_row("web-app-b", 0, ("compose",), pin_architecture="arm64")]
|
||||
entries = _assign(rows, sweep=0, tor_mode="auto", variants_per_app=_VARIANTS)
|
||||
self.assertEqual(entries[0]["architecture"], "arm64")
|
||||
|
||||
def test_a_pin_the_role_forbids_aborts_the_matrix(self) -> None:
|
||||
rows = [_row("web-app-b", 0, ("compose",), pin_architecture="amd64")]
|
||||
with (
|
||||
mock.patch.object(axes, "get_role_architectures", return_value=["arm64"]),
|
||||
self.assertRaises(SystemExit),
|
||||
):
|
||||
_assign(rows, sweep=0, tor_mode="auto", variants_per_app=_VARIANTS)
|
||||
|
||||
def test_the_architecture_survives_the_label_round_trip(self) -> None:
|
||||
entry = _assign(
|
||||
[_row("web-app-a", 0, ("swarm",))],
|
||||
sweep=0,
|
||||
tor_mode="enforced",
|
||||
architectures=("arm64",),
|
||||
variants_per_app=_VARIANTS,
|
||||
)[0]
|
||||
self.assertEqual(axes.parse_label(entry["label"]).architecture, "arm64")
|
||||
|
||||
def test_the_architecture_keeps_two_deploys_of_one_row_apart(self) -> None:
|
||||
"""Both architectures of one row upload artifacts, and
|
||||
actions/upload-artifact rejects the second upload under one name."""
|
||||
slugs = {
|
||||
axes.artifact_slug(
|
||||
"compose", "web-app-b", "0", False, "debian", "zfs", architecture
|
||||
)
|
||||
for architecture in pools.ARCHITECTURES
|
||||
}
|
||||
self.assertEqual(len(slugs), len(pools.ARCHITECTURES))
|
||||
|
||||
def test_every_declared_architecture_has_a_runner(self) -> None:
|
||||
"""A declared architecture without a label would make the matrix emit
|
||||
an empty ``runs-on``, which GitHub answers by queueing the job
|
||||
forever."""
|
||||
for architecture in pools.ARCHITECTURES:
|
||||
with self.subTest(architecture=architecture):
|
||||
self.assertTrue(pools.runner_of(architecture))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -18,8 +18,8 @@ class TestParseAscii(unittest.TestCase):
|
||||
|
||||
def test_every_axis_is_read(self) -> None:
|
||||
self.assertEqual(
|
||||
selection.parse("web-app-a#0,2@swarm+tor%debian/zfs"),
|
||||
selection.Pin("web-app-a", (0, 2), "swarm", True, "debian", "zfs"),
|
||||
selection.parse("web-app-a#0,2@swarm+tor%debian/zfs:arm64"),
|
||||
selection.Pin("web-app-a", (0, 2), "swarm", True, "debian", "zfs", "arm64"),
|
||||
)
|
||||
|
||||
def test_the_distro_and_filesystem_are_optional_like_the_rest(self) -> None:
|
||||
@@ -40,8 +40,17 @@ class TestParseAscii(unittest.TestCase):
|
||||
with self.assertRaises(SystemExit):
|
||||
selection.parse("web-app-a/xfs")
|
||||
|
||||
def test_an_architecture_alone_still_counts_as_a_narrowing(self) -> None:
|
||||
pin = selection.parse("web-app-a:arm64")
|
||||
self.assertEqual(pin.architecture, "arm64")
|
||||
self.assertTrue(pin.pinned)
|
||||
|
||||
def test_an_unknown_architecture_aborts(self) -> None:
|
||||
with self.assertRaises(SystemExit):
|
||||
selection.parse("web-app-a:riscv64")
|
||||
|
||||
def test_the_token_round_trips_through_describe(self) -> None:
|
||||
token = "web-app-a#0,2@swarm+tor%debian/zfs"
|
||||
token = "web-app-a#0,2@swarm+tor%debian/zfs:arm64"
|
||||
self.assertEqual(selection.describe(selection.parse(token)), token)
|
||||
|
||||
def test_clearnet_is_the_other_onion_state(self) -> None:
|
||||
@@ -96,6 +105,16 @@ class TestParseLabel(unittest.TestCase):
|
||||
selection.Pin("web-app-a", (2,), "swarm", True, "fedora", "btrfs"),
|
||||
)
|
||||
|
||||
def test_the_architecture_glyph_is_read_too(self) -> None:
|
||||
pasted = (
|
||||
f"{to_emoji('swarm')}{to_emoji('tor')}{to_emoji('fedora')}"
|
||||
f"{to_emoji('btrfs')}{to_emoji('arm64')}web-app-a#2"
|
||||
)
|
||||
self.assertEqual(
|
||||
selection.parse(pasted),
|
||||
selection.Pin("web-app-a", (2,), "swarm", True, "fedora", "btrfs", "arm64"),
|
||||
)
|
||||
|
||||
def test_a_title_pasted_with_its_caller_path_aborts_rather_than_guessing(
|
||||
self,
|
||||
) -> None:
|
||||
@@ -150,6 +169,18 @@ class TestApply(unittest.TestCase):
|
||||
self.assertEqual(kept[0]["pin_distro"], "debian")
|
||||
self.assertEqual(kept[0]["pin_filesystem"], "zfs")
|
||||
|
||||
def test_the_pinned_architecture_rides_along_too(self) -> None:
|
||||
kept = selection.apply(_ROWS, selection.parse_list("web-app-a#0:arm64"))
|
||||
self.assertEqual(kept[0]["pin_architecture"], "arm64")
|
||||
|
||||
def test_one_variant_that_failed_on_two_architectures_comes_back_twice(
|
||||
self,
|
||||
) -> None:
|
||||
kept = selection.apply(
|
||||
_ROWS, selection.parse_list("web-app-a#0:amd64 web-app-a#0:arm64")
|
||||
)
|
||||
self.assertEqual([row["pin_architecture"] for row in kept], ["amd64", "arm64"])
|
||||
|
||||
def test_one_variant_that_failed_on_two_distros_comes_back_twice(self) -> None:
|
||||
kept = selection.apply(
|
||||
_ROWS, selection.parse_list("web-app-a#0%debian web-app-a#0%fedora")
|
||||
|
||||
Reference in new issue
Block a user