Phase 4: Workflow integration (merge_group package-matrix job)¶
Master plan: PLAN-distro-matrix-ci.md. Planning effort: medium. Isolation: none. Depends on phases 1, 2, 2b, 3 — all complete as of 2026-08-11.
Rewritten 2026-08-11 from the original sketch. The sketch was written before phases 2b and 3 executed and before anyone had read the sibling repo's merge-queue implementation; it contained one instruction that would have broken PR CI (see "Corrections" below).
Objective¶
Add a package-matrix job to
.github/workflows/functional-tests.yml, gated to merge_group
events, that fans out over the seven matrix distros and runs
tools/test-package-functional.sh for each against a single shared
pair of build artifacts. Add the aggregate can_merge gate the queue
will require. PR events keep today's jobs; the matrix runs only in the
merge queue and on workflow_dispatch.
Grounding facts (verified 2026-08-11)¶
Read before changing anything; these were checked against the tree and the sibling repo, not recalled.
functional-tests.ymlhas nomerge_grouptrigger today. It fires onpull_request(with apaths:filter oversrc/**,crates/**,tests/**,scripts/**,tools/**,Makefile, and the workflow itself) andworkflow_dispatch. 807 lines, 9 jobs.- Job graph.
test-partition(gatedpull_request,srunner) ·build-and-test(s, ungated) ·package-smoke,integration-core,integration-convert-qcow2,integration-convert-vhd,snapshot-harnesses,oslo-crossval-master(allneeds: build-and-test, all[self-hosted, debian-12, xl]) ·automated_reviewer. - Only
test-partitioncarries an event gate. Every other job runs on whatever the workflow triggers on. Addingmerge_group:to the trigger list therefore re-runs the entire PR suite inside the queue unless each job is gated. This is the single largest decision in the phase (D2). package-smoke(line 260) is the artifact-producing precedent:make instar && make deb, thentools/test-package-install.shondebian:trixie. It builds the.debin-job and does not upload it.- Testdata prep is a solved step.
tools/ci/prepare-testdata.shclones withsecrets.GITLAB_TESTDATA_TOKEN, materialises LFS, canary-verifies, and writesTESTDATA_PATH=<path>to$GITHUB_ENV.integration-core(line 311) is the pattern to copy verbatim, including the resparsify step that follows it. - The runner's interface (
tools/test-package-functional.sh):[--smoke] [--select REGEX] [--concurrency N] <package> <distro-image>, readsTESTDATA_PATHfrom the environment, needs docker and/dev/kvm, prints a--- Versions under test ---block containingqemu-img --version, and ends withPASS:/FAIL: <pkg> functional suite on <distro>. It already fails loudly on a truncated run (the subunit-4MB worker death, a worker reportingN/Aelapsed, and a full run executing fewer than 2500 tests). - Package output paths.
make deb→src/target/debian/instar_*.deb;make rpm→src/target/generate-rpm/instar-*.rpm;make packagedoes both. Both are compile-free repackaging of whatmake instarproduced. - Sibling pattern of record —
shakenfist/shakenfist'sfunctional-tests.yml: - trigger is a bare
merge_group:alongsidepull_request:with abranches:(notpaths:) filter; - matrix jobs gate on
(github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch'), which is what makes a dispatch dry run possible; - the required check is an aggregate job,
can_merge, withif: always() && github.event_name == 'merge_group',needs:the matrix jobs, and a jq expression overtoJSON(needs)asserting every dependency issuccessorskipped.can_enqueueis its PR-side twin; - docs-only changes are handled by a
check_pathsjob usingdorny/paths-filter@v4withpredicate-quantifier: 'every', whosecode_changedoutput gates the heavy jobs — not by a triggerpaths:filter.
Corrections to the original sketch¶
- Step 4e was wrong and would have broken PR CI. It said to add the
matrix to "the aggregate gate (the
needs:list that the branch-protection required check depends on — see thepackage-smokeentry atfunctional-tests.yml:783)". Line 783 is insideautomated_reviewer, which is the PR auto-review job, not a gate. Itsneeds:list exists to hold the reviewer back until CI passes. Adding a merge_group-only job to it would make the reviewer skip on every PR, because a job whose dependency is skipped is itself skipped by default. This repo has no aggregate gate at all today; phase 4 must create one (can_merge, per the sibling), andautomated_reviewermust not be touched beyond an explicitpull_requestgate. merge_groupdoes not honour the trigger'spaths:filter. The filter onpull_requestis not inherited, so the workflow always runs in the queue. That is the safe direction — a required check that never runs would hang the queue forever — but it means a docs-only PR pays for the full matrix unless acheck_paths-style job is added (D4).- The matrix table's qemu estimates are stale. Phase 3 measured Rocky 9 shipping qemu 10.1.0, not the 8.2 the master plan estimated. Phase 2c/2b measured all seven. Take versions from those measurements, and let the job report the live version (4c) rather than asserting a table.
Decisions¶
D1. Trigger and gating shape¶
Add a bare merge_group: to the trigger list. Gate the new matrix and
its build job on
(github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch'),
matching the sibling exactly. The dispatch arm is what makes the phase
acceptance runnable without enqueuing anything.
D2. What the queue runs — recommendation: matrix + a fast build gate only¶
Because only test-partition is gated today, the naive change makes
the queue run seven xl integration jobs plus seven matrix
containers. Recommendation:
- Keep
build-and-testungated so it also runs in the queue — it is the cheaps-runner fast-fail, and the package build depends on a working build anyway. - Gate
package-smoke,integration-core,integration-convert-qcow2,integration-convert-vhd,snapshot-harnesses,oslo-crossval-master, andautomated_reviewertogithub.event_name != 'merge_group'.
The justification is coverage, not cost alone: each matrix entry runs the full suite (D3 of the master plan) against the packaged binary, so the queue's coverage is a superset of the PR integration jobs on seven distros rather than one. Re-running the PR jobs in the queue buys a second copy of a strictly weaker signal. Record this reasoning in the workflow as a comment — the next reader will otherwise "fix" the gates.
D3. Build once, consume seven times¶
Add a package-build job (needs: build-and-test, merge_group-gated)
that runs make instar && make package and uploads
src/target/debian/instar_*.deb and
src/target/generate-rpm/instar-*.rpm as one artifact. Each matrix
entry downloads it and picks the file its pkg_kind names.
This is only valid because phase 1 lowered the build floor to bullseye
(GLIBC_2.30), making one artifact set installable on all seven
distros — it is the concrete payoff of decision D1 in the master plan,
and phase 1's completion is what unlocks it. Assert it rather than
assume it: if a matrix entry ever fails at install time, that is a
floor regression, not a test failure.
D4. Docs-only changes in the queue¶
Recommendation: defer. Adopting the sibling's check_paths job is
the right long-term shape, but it is a change to how every job in
this workflow is gated and it is not needed for the matrix to work.
Ship the matrix first; revisit if docs-only merges prove painful. Note
it explicitly in the phase-5 handover so the operator knows a docs-only
PR currently pays full matrix latency.
D5. Fan-out control — full seven-wide, no max-parallel¶
fail-fast: false always: one distro's failure must not mask the other
six.
Do not set max-parallel. An earlier draft of this plan proposed
capping it, reasoning from seven containers at --concurrency 4 as "28
concurrent KVM workloads". That framing was wrong. The xl runners are
started on demand (confirmed with Michael 2026-08-11; the repo
distinguishes on-demand s/xl from the always-up [self-hosted,
static] pool, which it already uses in five places). Each matrix entry
therefore lands on its own VM: 4 concurrent KVM workloads per
runner, seven runners, no cross-entry contention. The phase-2c load
artifacts that motivated the cap came from running all seven on the
dev host simultaneously, which is not what CI does.
Capping the fan-out would serialise on-demand VMs that never compete for anything, multiplying merge-queue wall clock for no benefit. Since the matrix gates merges rather than pushes, one-entry-plus-runner-start latency is the right trade.
Steps¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 4a | medium | sonnet | none | Triggers and gates (D1, D2). Add bare merge_group: to on:. Add if: github.event_name != 'merge_group' to package-smoke, integration-core, integration-convert-qcow2, integration-convert-vhd, snapshot-harnesses, oslo-crossval-master, automated_reviewer; leave build-and-test ungated and test-partition as it is. Comment the why (D2's coverage argument) at the top of the job list. Verify with actionlint and by reading each job's resulting effective condition — do not add the matrix yet. |
| 4b | medium | sonnet | none | package-build job (D3). needs: build-and-test, gated merge_group \|\| workflow_dispatch, [self-hosted, debian-12, xl]. Copy package-smoke's docker install + docker image rm -f instar-build instar-release preamble. Run make instar && make package. actions/upload-artifact@v4 with a single named artifact containing both src/target/debian/instar_*.deb and src/target/generate-rpm/instar-*.rpm; if-no-files-found: error so a missing .rpm fails here rather than in seven confusing places. |
| 4c | high | opus | none | package-matrix job. needs: package-build, same gate, [self-hosted, debian-12, xl], strategy: {fail-fast: false} with no max-parallel (D5 — on-demand runners, one VM per entry), matrix.distro as a list of objects {name, image, pkg_kind} over the seven entries in the master-plan table (debian:12, debian:13, ubuntu:22.04, ubuntu:24.04, fedora:latest, rockylinux:9, rockylinux/rockylinux:10). name: "${{ matrix.distro.name }}". Steps: checkout into instar/ (mirror integration-core's two-checkout layout), prepare-testdata.sh with TESTDATA_TOKEN, the resparsify loop, docker install, download-artifact, then tools/test-package-functional.sh <resolved package> <image>. Resolve the package path by pkg_kind in a script, not inline YAML (per the no-large-scripts-in-workflow-steps rule) — extend tools/ci/ with a small resolver or add a --pkg-kind mode to the runner; decide and document which. timeout-minutes per R3. |
| 4d | medium | sonnet | none | Result surfacing. Tee the runner output; on completion append a row to $GITHUB_STEP_SUMMARY with distro name, image, the live qemu-img version parsed from the runner's --- Versions under test --- block, the Ran:/Passed/Skipped/Failed totals, and PASS/FAIL. Use if: always() so failures report too. Put the parsing in a tools/ci/ script, not inline YAML. The qemu version in the summary is what makes a red row attributable to a version boundary rather than a packaging bug (phase 2/2b lineage). |
| 4e | medium | sonnet | none | Flake quarantine (master-plan policy). Support an optional allow_failure: true key on a matrix entry, consumed as continue-on-error: ${{ matrix.distro.allow_failure \|\| false }}. Default no entry to it. Document in the workflow comment and docs/testing.md: an entry that fails twice consecutively for a reason established as environmental gets the flag with a linked issue, and the flag is removed when the issue closes. Note the sharp edge — a continue-on-error job reports success to the needs context, so a quarantined entry genuinely stops gating. |
| 4f | medium | sonnet | none | can_merge aggregate gate (corrects sketch 4e). New job: needs: [package-build, package-matrix], if: always() && github.event_name == 'merge_group', runs-on: [self-hosted, static] (it is a five-second jq check — do not boot an on-demand VM for it; the sibling's can_merge/can_enqueue use static for the same reason, and this repo already uses that label in five places), permissions: {actions: read}, and the sibling's jq body — ALL_SUCCESS=$(echo "$NEEDS_JSON" \| jq '. \| to_entries \| map([.value.result == "success", .value.result == "skipped"] \| any) \| all') then [ $ALL_SUCCESS == true ]. Do not touch automated_reviewer's needs:. This job's name is what phase 5 makes the queue's required check. |
| 4g | low | sonnet | none | Docs. docs/testing.md: the PR-vs-merge-queue job split, the seven entries, the quarantine policy, and how to reproduce one entry locally (tools/test-package-functional.sh with --select). AGENTS.md/ARCHITECTURE.md: a pointer only, no duplication. CHANGELOG [Unreleased]. Update the master plan's phase-4 row and its stale qemu-version estimates against the phase-2c/2b measurements. |
Execution results (2026-08-11)¶
Implemented 4a-4g. Three new scripts keep the workflow declarative, per
the no-large-scripts-in-CI-steps rule: tools/ci/resolve-package.sh
(glob one package of a kind, failing loudly on zero or two matches),
tools/ci/summarise-matrix-entry.sh (parse the runner's log into a
job-summary row), and tools/ci/run-matrix-entry.sh (the per-entry
wrapper CI calls).
Verified locally:
- Job graph simulated per event.
pull_requestruns exactly today's nine jobs — no matrix, nocan_merge, no added latency.merge_groupruns onlybuild-and-test,package-build,package-matrix,can_merge.workflow_dispatchruns everything excepttest-partitionandcan_merge, which is the dry-run path. - Both package families end-to-end through the real wrapper:
Debian 13/.deb/ apt reportedqemu-img 10.0.11, andRocky 9/.rpm/ dnf reportedqemu-img 10.1.0, each producing a correct summary row. (Run withMATRIX_SELECTto bound cost; the full-suite behaviour is phase 3's, unchanged.) - The summariser degrades correctly: a run that dies before the
versions block reports
qemu-img unknownand?totals rather than failing, and a missing log warns instead of crashing. resolve-package.shrejects both the no-match and multi-match cases.actionlintandshellcheckclean.
One design change from the plan. allow_failure is declared
explicitly as false on all seven entries rather than being an
optional key: actionlint types the matrix object from its literal
entries, so an absent property is an error even behind || false.
Declaring it per entry is better anyway — quarantine state is visible
per row, and flipping one is a one-word diff.
First live fan-out (run 31533536833, 2026-08-11)¶
workflow_dispatch on matrix-ci. All seven entries PASS, 0
failures, spanning six qemu-img versions from 6.2.0 to 10.2.2:
| Distro | qemu-img | Ran | Passed | Skipped | Failed | Duration |
|---|---|---|---|---|---|---|
| Ubuntu 22.04 | 6.2.0 | 3262 | 2477 | 785 | 0 | 34 min |
| Debian 12 | 7.2.22 | 3262 | 2477 | 785 | 0 | 23 min |
| Ubuntu 24.04 | 8.2.2 | 3262 | 2477 | 785 | 0 | 39 min |
| Debian 13 | 10.0.11 | 3262 | 2477 | 785 | 0 | 35 min |
| Rocky 9 | 10.1.0 | 3262 | 2415 | 847 | 0 | 25 min |
| Rocky 10 | 10.1.0 | 3262 | 2415 | 847 | 0 | 48 min |
| Fedora latest | 10.2.2 | 3262 | 2477 | 785 | 0 | 28 min |
Every entry ran the same 3262 tests. The two RHEL rows skip exactly 62
more than the rest, which is skip_unless_qemu_supports() handling
RHEL's qemu omitting the qed/qcow/parallels/dmg/bochs/cloop drivers
(phase 2) — a consistent, explained difference, not drift.
This also confirms phase 2b against live old qemu rather than the static per-version binaries it was measured with: Ubuntu 22.04 (6.2.0) and Debian 12 (7.2.22) were 21 failures each before 2b, and Ubuntu 24.04 was 2.
Timings (R1/R3 answered). Per-entry suite runtime is 23-48 min,
median ~34 — well inside the 120-minute timeout, which can stay as
headroom. The whole matrix took 89 minutes wall-clock (01:14 →
02:43) in two waves, so the effective xl concurrency was about four
then three, not seven. Provisioning, not the cap we declined to set, is
what serialises the fan-out; max-parallel would only have made this
worse, so D5 stands even though its "one entry plus boot" latency
estimate was optimistic.
Note this dispatch is the worst case for scheduling: it runs the PR
jobs and the matrix, and it competed with a concurrent PR run
(31533917315) for the same pool. package-build sat queued 2h20m as a
result. A real merge_group event runs only build-and-test,
package-build and the matrix, so it should schedule better than this.
Still outstanding:
GITLAB_TESTDATA_TOKENon amerge_groupevent specifically. The token demonstrably works onworkflow_dispatch, which is strong evidence (secrets are exposed the same way to non-fork events) but not the same event class. First queue run confirms it.can_mergehas never reported — it ismerge_group-only, so its check context does not yet exist in this repository. That matters for phase 5: requiring a context GitHub has never seen is the one way to jam every merge.
Acceptance¶
- A
workflow_dispatchdry run fans out over all seven distros, each installing the shared artifact and running the full suite. - Build-once/consume-many confirmed: one
.deband one.rpmfeed all seven entries, and no entry rebuilds instar (D3, the phase-1 payoff). - The job summary names each distro, its live qemu-img version, its test totals, and its result.
pull_requestruns are unchanged: same jobs, no matrix, no added latency. Verify by reading a real PR run, not by reasoning.can_mergeexists, aggregates the matrix, and reports only onmerge_group.automated_reviewerstill runs on PRs.- Measured wall-clock recorded in this file, split into runner boot + image pull versus suite runtime, so phase 5 can size the queue (R1, R3).
actionlintandshellcheckclean viapre-commit run --all-files.
Risks¶
- R1 — RESOLVED 2026-08-11: runner pool elasticity. The
xlrunners are started on demand, so seven concurrent entries do not starve a fixed pool andmax-parallelis unnecessary (D5). What remains is cost, not contention: each entry boots a VM and pulls a distro image before any test runs, and the queue pays that seven times per merge. Measure the boot+pull overhead separately from suite wall-clock in 4c's dispatch run, so phase 5 can size the queue honestly. - R2 — KVM contention within an entry. Cross-entry contention is
not a risk (D5: one VM per entry). Within an entry, the container
still runs at
--concurrency 4, which is the setting phase 3 made tunable for exactly this reason. Phase 2c's nine load-artifact failures came from co-located runs on the dev host, so they do not predict CI behaviour — but the failure mode is still live at any concurrency, and any first-run divergence must be classified by isolated--selectreplay before being called a regression (memory: diffuzz_spurious_divergence_contention). - R3 — timeouts are unmeasured. The suite is ~3250 tests; the dev
host does ~15 min across 16 workers, and phase 3 flagged that
--concurrency 4in one container is materially longer without recording the number. Set generoustimeout-minutes(90 as a starting point), measure on the first dispatch run, then tighten. A timeout that fires mid-suite in the queue looks like a flaky distro. - R4 —
GITLAB_TESTDATA_TOKENinmerge_group. Downgraded 2026-08-11: Michael is confident the token is available to merge CI, so this is no longer a design risk and needs no contingency. It stays listed only because the failure mode is deceptive — a queue run that cannot fetch testdata mounts LFS pointer files and produces a mass "file format: unknown" result that reads as an instar regression rather than a credentials problem (memory: testdata_lfs_pointer_drift).prepare-testdata.shcanary-checks for exactly this, so it will fail loudly and early; no separate verification step is needed. - R5 — a skipped dependency skips its dependents. The mechanism
behind the sketch's broken 4e. Any
needs:edge crossing the PR/merge_group boundary must useif: always() && <event test>, ascan_mergedoes. Re-check everyneeds:after 4a's gating changes. - R6 —
continue-on-errorreports success. A quarantined entry (4e) does not merely tolerate failure; it becomes invisible tocan_merge. That is the intent, but it means a forgotten flag silently drops a distro from the gate. Tie each flag to an open issue.
Out of scope¶
- Enabling the merge queue itself and the verification merge — phase 5.
- Adopting
check_paths/dorny/paths-filterfor docs-only skipping (D4) — deferred, noted for phase 5. - Any change to instar behaviour or to the runner script's semantics. If a matrix entry finds a real parity gap, it is a new phase, not a fix folded in here (this is how phase 2b came to exist).
- Publishing the built packages anywhere — they are CI artifacts only.