The pipeline gate only listed twelve of seventeen jobs in its needs, leaving out wait-fork-prereq-run, fork-prereqs-ready, build-ci-images, images-mirror-missing and test-dns. A failure in any of them turns its dependents into skipped rather than failure, and the gate's check matches only failure and cancelled, so the run would have concluded green without images ever being built.
A caller-supplied whitelist was also passed through verbatim. Run 30492550883 was dispatched with web-app-prometheu; discovery dropped the unknown id without a warning, the empty resolution became [], the deploy workflows skipped their whole matrix on that condition and the parent job still concluded success. Validate every id against roles/<id> and exit naming the offenders, so a typo cannot quietly remove an application from a run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sweep's own governor was sized against the 6h platform kill (19800s) while the deploy step was capped at 300 minutes, so it could never fire: GitHub hard-killed the step mid-distro and reported a run that simply did not fit all distros as a job failure.
Size the ladder instead, 10 and 5 minutes apart: sweep budget 340min < deploy step 350 < job 355 < platform kill 360. The swarm per-leg ceiling moves to 350 so it stops matching the job cap, where it was dead code. The rescue-state dump drops from 10 to 5 minutes so it cannot alone consume the window between the job kill and the platform kill; measured teardown is 10-38 seconds.
A new lint asserts the budget stays under the governing step cap, the lower half of the invariant whose upper half the headroom lint already carried; that lint's ceiling moves from 345 to 355.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both steps resolved their value as `echo "X=$(python3 ...)"`. A command substitution's exit status is discarded when it is only an argument, so echo's 0 won: the step stayed green and wrote an empty value. Measured against the real module -- old shape exit 0 with `INFINITO_DISTRO=`, assignment form exit 1 and nothing written. The empty matrix then surfaced as an unexplained fromJson error one job later, and the empty distro as `INFINITO_IMAGE=infinito-` deep inside the docker run, where set -u cannot help because GITHUB_ENV defined the variable as empty rather than unset.
The prepare step also had no `set` line of its own; it inherited -eo pipefail from the shell wrapper and never had -u.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The distros input carried the full five-distro list as a default on ci-orchestrator and on both triggers of test-deploy-swarm, so a caller that forgot it silently inherited everything -- which is how a single workspace leg ended up drilling all five distros sequentially. test-deploy-compose already required the value; make swarm and the orchestrator match it. Every caller passes distros explicitly today, and entry-manual keeps the only default a human ever sees.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every workspace leg pinned INFINITO_DISTRO=debian and drilled all five distros sequentially, pulling its swarm nodes from ghcr <distro>:latest -- an image from 2026-03-21 whose nfs-ganesha 7.2 segfaults on any accepted TCP connection on 2049. The controller mount then failed with ECONNREFUSED for 26 minutes and took run 30446813110 down after 5h13m, with 3 of 5 distros never reached.
Derive the distro from the leg's own matrix entry, build that image, and drill only it: swarm-zombie takes an optional distros= that defaults to INFINITO_DISTRO, and 00_one.sh prefers the locally built image over the published one. 12_swarm.sh refuses to start when that image is missing, so a broken build fails loudly instead of being masked by a stale registry pull.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both steps resolved their value as `echo "X=$(python3 ...)"`. A command substitution's exit status is discarded when it is only an argument, so echo's 0 won: the step stayed green and wrote an empty value. Measured against the real module -- old shape exit 0 with `INFINITO_DISTRO=`, assignment form exit 1 and nothing written. The empty matrix then surfaced as an unexplained fromJson error one job later, and the empty distro as `INFINITO_IMAGE=infinito-` deep inside the docker run, where set -u cannot help because GITHUB_ENV defined the variable as empty rather than unset.
The prepare step also had no `set` line of its own; it inherited -eo pipefail from the shell wrapper and never had -u.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The distros input carried the full five-distro list as a default on ci-orchestrator and on both triggers of test-deploy-swarm, so a caller that forgot it silently inherited everything -- which is how a single workspace leg ended up drilling all five distros sequentially. test-deploy-compose already required the value; make swarm and the orchestrator match it. Every caller passes distros explicitly today, and entry-manual keeps the only default a human ever sees.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every workspace leg pinned INFINITO_DISTRO=debian and drilled all five distros sequentially, pulling its swarm nodes from ghcr <distro>:latest -- an image from 2026-03-21 whose nfs-ganesha 7.2 segfaults on any accepted TCP connection on 2049. The controller mount then failed with ECONNREFUSED for 26 minutes and took run 30446813110 down after 5h13m, with 3 of 5 distros never reached.
Derive the distro from the leg's own matrix entry, build that image, and drill only it: swarm-zombie takes an optional distros= that defaults to INFINITO_DISTRO, and 00_one.sh prefers the locally built image over the published one. 12_swarm.sh refuses to start when that image is missing, so a broken build fails loudly instead of being masked by a stale registry pull.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A manual dispatch defaulted both dropdowns to 'false', so run 30445662106
skipped test-workspace and test-instructions even though nothing about
the run asked for that. Default to 'auto' instead, which applies the same
scope rule push and pull_request runs already use: run on a global scope,
skip on a role-scoped one. 'true' and 'false' stay available to force
either way, and 'auto' now leads the option list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test-environment.yml and test-guide.yml become test-workspace.yml and
test-instructions.yml. Orchestrator job ids, the distros-SPOT matrix
lint, the act example, the diagnostics slug and the rescue artefact
names follow.
Replace the roles_only input with per-job workspace/instructions
tri-state inputs (auto|true|false). 'auto' derives the former
roles_only skip straight from whitelist: both jobs run on a global
scope (whitelist empty or __ALL__) and are skipped on a role-scoped
run, which keeps the PR behaviour identical because the diff resolver
never emits an empty whitelist. entry-manual exposes both as choice
dropdowns defaulting to false, so a manual run opts in explicitly.
The now redundant roles_only output is dropped from
pr_affected_roles.sh and subset_roles.py.
Branch-protection required-status-check names change along with the
job renames.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The orchestrator is a reusable workflow whose single caller job carries
its own concurrency group, not the caller's. That group was pinned to
cancel-in-progress: false, so a newer run could never displace a running
one on the inner level and parked as pending with zero jobs instead.
Run 30390890384 hit exactly that: its run-level status had degraded to
queued while the matrix waited for runners, so the outer global-ci cancel
did not fire against it, and run 30391785197 blocked behind the inner
group indefinitely.
The orchestrator now takes a force_cancel_in_progress input and otherwise
follows CI_CANCEL_IN_PROGRESS like the push entry workflow does. The
manual entry workflow passes true, so it cancels on both levels and no
longer depends on the outer cancel firing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
guide_select picked the role, the mode and the distro in one draw, so a run proved the README instructions worked on exactly one randomly chosen distribution. The distro list was also a literal in the workflow, which meant the manual-CI distros input never reached the guide at all.
The distro draw is removed. guide_select still picks the role and derives the mode, while the shared loop from 1bbe7a0eb drives the distro dimension, and ci-orchestrator forwards its distros input.
The four mode-specific steps collapse into guide/one.sh, which the loop runs once per distro: it renders the pkgmgr base image for the current distro, then either execs host_deploy.sh or brings the compose server up and replays the README block against it.
one.sh releases the compose server in an EXIT trap. Without it the second distro would start against the stack the first one left behind. Rescue diagnostics gain a distro segment for the same reason the swarm collectors did.
The job timeout goes from 90 to 345 minutes because up to five rounds now have to fit; the loop's own budget still decides how many actually run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The discover job was named after the full distro list and the plan summary printed all five, but test-deploy-swarm.yml never set INFINITO_DISTROS, so every run fell back to the default.env distro and only debian was ever exercised. The job name has been advertising coverage that did not exist.
The workflow now passes the distros input into the env and hands the drill to the shared loop from 1bbe7a0eb. Bootstrap, setup, matrix deploy, seed, drain and assert move out of eleven separate steps into routine/00_one.sh, which is what the loop runs once per distro.
Collection and teardown move with them, into an EXIT trap: it replaces the removed 'if: always()' and 'if: failure()' step conditions and returns the original exit code so cleanup cannot mask the deploy failure. The always() teardown step stays as a second net for a step that times out as a whole.
Playwright reports and rescue diagnostics gain a distro segment, matching the layout the compose path already writes, because a second distro would otherwise overwrite the first one's evidence. The structure summary carries the distro in its heading for the same reason.
The README described the old shape and is corrected: the loop entries are listed, topology_summary.sh was missing, and 01_bootstrap.sh is no longer 'one CI step'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
images-build-ci.yml and test-environment.yml both carry a prepare job
that computes a matrix for the job behind it. This one was named after
its side effect rather than its function and named no consumer, so the
two now read as one class: 📋 Build Matrix feeds 🏗️ Build, 📋 Environment
Matrix feeds 🧑💻 Environment.
Display name only. needs and outputs reference the job key, not the
name, and no file in the repository refers to the old one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The five images in test-environment.yml were a verbatim copy of the
dev_runtime_image field of every distro in meta/distros.yml. A prepare
job now reads them through utils.distros and publishes a JSON array that
the matrix consumes with fromJson, so adding or dropping a distro no
longer needs a workflow edit. The resolver runs on the bare interpreter
because utils/distros.py is stdlib-only.
An empty list aborts the resolver: GitHub skips a job with an empty
matrix silently, which would leave the run green with nothing tested.
The lint could no longer compare literals, so it walks the wiring
instead: axis expression to needs edge to job output to step id, then
runs that step body the way the runner does and compares what it writes
to GITHUB_OUTPUT against the SPOT. The Act example in the iteration docs
was the last remaining literal and now reads the SPOT too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A workstation has no GHCR mirror and no node registry cache, so the same
deploy that CI finishes in ~70min median needs far longer locally and
died at the 345min step limit. The CI value and its 15min headroom to
the platform cap stay untouched; swarm-zombie raises it locally.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
categories.yml moves from roles/ to the repository-root meta/, joining packages.yml. utils/roles/categories.py owns its location plus the tree-walking helpers that were stranded in entity/name.py, so no caller spells the path or duplicates the lookup.
meta/distros.yml becomes the SPOT for every distribution: os_family, Galaxy platform name, dev-environment base image, and the pkgmgr/pkgmgr-virgin/environment reference templates. It replaces seven scattered copies (DISTRO_FAMILY, VALID_DISTROS, _DISTROS, _CANONICAL_PLATFORMS, two shell arrays, a Makefile literal). INFINITO_DISTROS is no longer a static default but generated by utils/env/handlers/infinito/distros.py; the shell consumers read it through the env layer.
Drops dead CLI surface found along the way: --categories-file and --roles-dir on the provision CLI plus three unused parameters of generate_dynamic_inventory, and the positional roles_file of the invokable categories command.
tests/lint/repository/test_distros_spot.py pins the copies that cannot call the SPOT (workflow literals, docs table) and forbids composing a pkgmgr reference outside it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
categories.yml moves from roles/ to the repository-root meta/, joining packages.yml. utils/roles/categories.py owns its location plus the tree-walking helpers that were stranded in entity/name.py, so no caller spells the path or duplicates the lookup.
meta/distros.yml becomes the SPOT for every distribution: os_family, Galaxy platform name, dev-environment base image, and the pkgmgr/pkgmgr-virgin/environment reference templates. It replaces seven scattered copies (DISTRO_FAMILY, VALID_DISTROS, _DISTROS, _CANONICAL_PLATFORMS, two shell arrays, a Makefile literal). INFINITO_DISTROS is no longer a static default but generated by utils/env/handlers/infinito/distros.py; the shell consumers read it through the env layer.
Drops dead CLI surface found along the way: --categories-file and --roles-dir on the provision CLI plus three unused parameters of generate_dynamic_inventory, and the positional roles_file of the invokable categories command.
tests/lint/repository/test_distros_spot.py pins the copies that cannot call the SPOT (workflow literals, docs table) and forbids composing a pkgmgr reference outside it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
moby/buildkit was hardcoded in four places across two workflows and catthehacker/ubuntu in two act scripts, none of them reachable by the image mirror: utils/docker/image/discovery.py only scans roles/**/meta/services.yml. So the one image the CI build cannot proceed without was pulled straight from Docker Hub, unmirrored, which is what broke the fedora build in run 30160077828.
Declare both as mirror-only services on svc-runner, following the test-e2e-playwright precedent, so the existing mirror pipeline picks them up unchanged. New scripts/meta/resolve/image/service_ref.sh reads image+version back out of the declaration and is the single source for consumers outside Ansible; its mode argument is mandatory (upstream never touches the network, mirror prefers the GHCR tag and falls back to upstream). The fallback is why the mirror job can keep running in parallel with the build instead of gating it.
ACT_PLATFORM_IMAGE moves to an env handler so act consumers read the declared value via BASH_ENV, and workflow.sh now demands it instead of defaulting to a literal. Operator overrides still win through eb.setdefault.
Verified: service_ref.sh in all four paths (upstream, mirror hit, missing mode, invalid mode); the mirror path matches GHCRProvider.image_base() character for character and resolves against the live registry anonymously; ACT_PLATFORM_IMAGE resolves through the real BASH_ENV mechanism; make quality green on all four targets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A workstation has no GHCR mirror and no node registry cache, so the same
deploy that CI finishes in ~70min median needs far longer locally and
died at the 345min step limit. The CI value and its 15min headroom to
the platform cap stay untouched; swarm-zombie raises it locally.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Environment matrix was the only CI path that produced no artifact at
all: no Playwright reports, no container state, no container logs. When
all five jobs of run 30254851245 failed on a dead prometheus backend, the
job log carried the 502 and nothing else, so the reason the container was
not serving could not be determined from CI at all.
Reuse the existing rescue-diagnostics SPOT instead of adding a second
capture path. Three things make it work here:
- the job mounts the runner docker socket, so `make compose-up` creates
infinito_nexus_debian on the runner daemon and it outlives the `--rm` of
the orchestrator container;
- the app stack sits one level deeper inside that container's own dockerd,
and container.py recurses into any running container carrying python3
and a container runtime;
- nothing tears the stack down on failure: 00_orchestrator.sh runs under
`set -euo pipefail` with no trap, and 10_teardown.sh is the only
`make compose-down` in the suite and never reached.
The image ref needs a slug because artifact names cannot contain `/` or
`:`, and the matrix values are refs like quay.io/centos/centos:latest.
`|| true` mirrors dedicated.sh: container.py always returns 1 by design so
a failing pipeline stays failing.
Probed locally against a running infinito_nexus_debian: recursion reaches
containers/infinito_nexus_debian/nested/, per-container inspect and logs
are captured with State.ExitCode, State.OOMKilled and RestartCount intact,
24 containers in roughly 20 seconds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
moby/buildkit was hardcoded in four places across two workflows and catthehacker/ubuntu in two act scripts, none of them reachable by the image mirror: utils/docker/image/discovery.py only scans roles/**/meta/services.yml. So the one image the CI build cannot proceed without was pulled straight from Docker Hub, unmirrored, which is what broke the fedora build in run 30160077828.
Declare both as mirror-only services on svc-runner, following the test-e2e-playwright precedent, so the existing mirror pipeline picks them up unchanged. New scripts/meta/resolve/image/service_ref.sh reads image+version back out of the declaration and is the single source for consumers outside Ansible; its mode argument is mandatory (upstream never touches the network, mirror prefers the GHCR tag and falls back to upstream). The fallback is why the mirror job can keep running in parallel with the build instead of gating it.
ACT_PLATFORM_IMAGE moves to an env handler so act consumers read the declared value via BASH_ENV, and workflow.sh now demands it instead of defaulting to a literal. Operator overrides still win through eb.setdefault.
Verified: service_ref.sh in all four paths (upstream, mirror hit, missing mode, invalid mode); the mirror path matches GHCRProvider.image_base() character for character and resolves against the live registry anonymously; ACT_PLATFORM_IMAGE resolves through the real BASH_ENV mechanism; make quality green on all four targets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Swarm's nested DinD-node daemons plus the backup DR drill (LUKS loop image,
per-node registry pulls) fill the ~50 GB root fs and trip the <6 GB abort
guard in utils/tests/swarm/matrix.py, while /mnt is a separate ~74 GB
partition sitting idle behind only a swapfile.
Add a runner step that moves the host Docker data-root to /mnt/docker when
/mnt is a separate, roomier partition (detection mirrors enlarge_swap.sh),
before any Docker usage. Best-effort: any failure rolls Docker back onto /
and the step still exits 0, so it can never turn a green deploy red. Wired
into the compose, swarm, and host deploy workflows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a main run, report-main-failures parses the failed deploy jobs, downloads
each role's rescue-diagnostics artifact, and opens a 'CI failure: <role>' issue
(label ci-failure) carrying the run link, the artifact names, and the decisive
rescue excerpt inline. An existing open issue for the role gets a comment
instead. Mode emojis are resolved through the symbol glossary, not hardcoded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A push resolves the application roles that exist under roles/ locally but
not at origin/main and passes them as the orchestrator priority line, so
brand-new roles deploy and gate ahead of the regular matrix. The resolver
is stdlib-only (git ls-tree plus an application_id line scan) so it runs
on a bare runner without installing the resolver stack, and yields an
empty set when the baseline ref cannot be resolved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
host_deploy.sh drops its bespoke dump: the EXIT trap now runs
utils/diagnostics/container.py on failure before removing the boot
container, because the workflow's failure() collect step only fires
after this script has already torn the container down. That yields the
full snapshot (logs, inspect, in-container systemctl and journal, DiD
recursion) instead of three ad-hoc files, and successful runs skip the
capture entirely.
The compose-mode README replay moves out of test-guide.yml into
scripts/github/guide/compose_deploy.sh unchanged; the workflow step
becomes a one-liner.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
INFINITO_RESCUE_DIAGNOSTICS_BASE joins default.env as the only literal
of the runner-side collection root and is wired through the passthrough
handler into the generated .env. The four deploy workflows load it with
one grep bootstrap step after checkout instead of hardcoding the path in
their env blocks; upload paths and per-app DIR derivations reference the
variable. dedicated.sh sources it from .env alongside the existing
diagnostics keys and hard-requires it, no script-level default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A bare '/' does not recurse for manifest ecosystems, so the npm entry
missed every role-level files/package.json and composer/bundler were
pinned to single hardcoded paths. Use both glob spellings per entry:
'**' is proven in-repo to match root manifests (compose.yml PRs) and
'**/*' is the documented recursive form, so together they cover both
interpretations of the backend's glob expansion.
The commented-out ecosystem stubs stay as the intentional catalogue of
deliberately unscanned ecosystems; the file opts out of the comment lint
to keep them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Conflicts: keep both new passthrough env vars; adopt the swarm-nfs
ollama manager-ops split while porting our model-pull retry hardening
into it. Strip three narration comments the stricter comment lint
rejects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a run deploys both single-node modes, the host discovery redeployed roles the compose line already covers (e.g. web-svc-html in both matrices). The single-node workflow now signals a co-running compose line via INFINITO_HOST_EXCLUDE_COMPOSE (a run-context registry key like INFINITO_DEPLOY_MODE), and the host query excludes the compose line's actually-triggered selection before its own budget cut, so freed host slots go to roles compose does not cover. Priority and regular lines each filter against their own compose counterpart through the identical whitelist/blacklist pass-through; host-only runs stay unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Priority deploys gate via implicit success() on new lint/test needs.
Regular deploys carry always() in their if, so needs alone would only
order, not gate; explicit result checks make red lint/test skip them.
done keeps lint/test as unconditional backstop since the skip cascade
would otherwise turn red lint/test into a green pipeline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
crs-k/stale-branches rejects branches-filter-regex over 50 chars;
equivalent lookahead-only form is 49 and needs no trailing .* since
the action uses RegExp.test without anchoring.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The catthehacker Act runner image lacks the project's pyyaml plus gettext-base/jq that GitHub's ubuntu-latest ships, so guide_select and the compose deploy step fail under act. Install them in an ACT-gated step (no-op on real CI).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read INFINITO_LIFECYCLES from default.env via a new tested_lifecycles() helper; derive TESTED_LIFECYCLES, the README overview envelope, the guide-select role filter, and the invokable-lifecycle lint from it. Add "maintained" to the envelope and drop the redundant hardcoded "alpha beta rc stable" strings from workflows, CLI help, and docs.
Guide selection now skips pre-alpha/eol roles (153 -> 117 testable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The guide job had no failure diagnostics, so an unhealthy container such as
decidim failed the deploy with no logs to inspect. Run the recursive
container.py rescue collector on failure and upload the snapshot, mirroring
the deploy-swarm and deploy-compose jobs. In compose mode the collector
descends into the persisted INFINITO_CONTAINER and captures the nested app
containers' logs, inspect and health status.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the inline fork-prerequisites result assertion out of the
fork-prereqs-ready job into scripts/github/gate/fork_prereqs.sh (result
passed via the RESULT env var; the job now checks out the repo).
The staged diff also drops the code-quality-gate and test-install-gate
aggregator jobs and wires their members (lint, test, security-codeql,
test-install-make, test-install-pkgmgr) directly into the done job's needs,
which still hard-fails on any upstream failure or cancellation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the per-distro DNS test matrix into a single job that runs each
triggered distro in its own systemd Docker-in-Docker container, built from
that distro's prebuilt CI image, all in parallel. Each container installs
and starts a local docker daemon, brings the dev stack up and runs the
existing dns.sh suite unchanged. The local daemon makes the dev compose
bind-mounts resolve against the baked repo, and the per-container network
namespace keeps the static CoreDNS IP and subnet from colliding across
distros. GITHUB_ACTIONS=true and INFINITO_BUILD=0 pin the deploy to the
pull-and-run path (no cache stack, no local image build); dig is installed
unconditionally so arch keeps the host-query assertions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the 5-distro matrix in the make and pkgmgr install-test workflows
into a single job that runs every distro in parallel inside extracted
scripts, cutting 5 runners down to 1 per workflow. Each distro installs
against its own copy of the checkout so the concurrent in-container builds
never race on the shared tree. The distro list is read from the
INFINITO_DISTROS SPOT in default.env instead of being hardcoded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>