Skip to content

feat(quality): derive the Nextcloud test matrix from appinfo/info.xml - #448

Merged
rubenvdlinde merged 3 commits into
mainfrom
feat/derive-nextcloud-matrix-from-info-xml
Aug 13, 2026
Merged

feat(quality): derive the Nextcloud test matrix from appinfo/info.xml#448
rubenvdlinde merged 3 commits into
mainfrom
feat/derive-nextcloud-matrix-from-info-xml

Conversation

@rubenvdlinde

@rubenvdlinde rubenvdlinde commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What this changes

quality.yml no longer takes the Nextcloud test matrix from a hand-written
input. It derives it from the app's own appinfo/info.xml, using
icewind1991/nextcloud-version-matrix — the same action Nextcloud's
own app CI uses.

The point is not tidiness. The fleet has already broken the info.xml ↔ matrix
agreement in both directions inside one programme: first all 18 apps declared
NC 32–34 while testing stable31/stable32, then the migration that fixed that
replaced the list with '["stable34"]' and 16 apps stopped testing their own
declared floor. Both were detectable; neither was detected, because they were two
files that could be edited independently. Deriving makes the disagreement
unrepresentable rather than merely detected.

Supply chain

The action is pinned by commit SHA: 8a7bac6300b2f0f3100088b297995a229558ddba,
which is tag v1.3.2 — the newest release. The tag is written in a trailing
comment so a human can read what the SHA means; the SHA is what the runner
resolves, so a moved tag cannot change what executes.

Deliberately not the action's tip: main carries an unreleased ES-module /
node24 rewrite (src/action.mjs, dist/index.mjs). v1.3.2 is node20 and
dist/index.js. We take releases, not commits-since-a-release. 23 repositories
consume this workflow at @main, so this is a supply-chain decision, not a style
one.

The ordering trap, and how it is handled

branches comes out oldest-first — verified in the action's source, where
range(min,max) is reversed for the branched-off check and reversed back, and
verified again by executing the pinned dist/index.js:

info.xml 32-34  ->  branches = ["stable32","stable33","stable34"]   branches-max = stable34

Six sites read fromJSON(inputs.nextcloud-test-refs)[0] as THE single server
for newman, playwright, journeydoc-capture and the coverage guard, and
newest-first was deliberate there — stable34 at [0] is what surfaced eight
dead NC32/33-only selectors on portaliq. Consuming branches verbatim would have
moved all four onto stable32, silently.

The array is not reversed. All six sites now read
needs.nextcloud-matrix.outputs.single-server, which on the derived path is the
action's own branches-max output. A positional convention is invisible in a
diff and cannot be checked; an output named single-server can be, and is
printed on every run.

quality-resolve-probe.yml additionally asserts the oldest-first ordering, so
a future release that flipped it surfaces as a red check rather than as four jobs
quietly testing the wrong server.

Compatibility — no flag day

nextcloud-test-refs is kept as an explicit override; derivation happens only
when it is unset.
Chosen over a staged opt-in input because the override is
the staging mechanism: every caller that passes one keeps byte-identical
behaviour, and adopting derivation is a one-line deletion per repo, on that
repo's own schedule.

Measured across all 23 callers rather than assumed:

callers today after this PR
18 core apps pass ["stable34","stable32","stable33"], info.xml 32–34 unchanged (override honoured)
openklant, opentalk, openzaak pass nothing → shared default ["stable34","stable32","stable33"]; info.xml 32–34 derive ["stable32","stable33","stable34"] — same set, same 6 legs, same stable34 single server (derivation executed against each app's real info.xml)
nextcloud-vue pass nothing, no appinfo/info.xml falls back to the same range, with source=fallback printed
petstore ["stable31","stable32","stable33"], info.xml 28–34 override honoured; single server moves stable31stable33
hrmq, planix ["stable34"], info.xml 32–34 unchanged (they are already a gate-65 rule 11 finding)
valtimo, ollama-nextcloud, open-webui-nextcloud no code-quality.yml n/a
n8n-nextcloud does not call this workflow at all — its code-quality.yml is a hand-rolled Python job n/a for the workflow; see the gate-65 delta below

The one behavioural change is petstore, and it is the change from
single-server = refs[0] to single-server = max(refs). stable31 is a version
this workflow's own comments record as producing meaningless results — openregister
declares min-version="32", so occ app:enable openregister failed with only a
::warning::, the run continued with no data layer, and every
/apps/openregister/... call returned Nextcloud's HTML 404 page. Moving off it is
a fix, but it is a change and it is called out rather than buried.

A disagreeing override emits a ::warning:: naming both sets. It is deliberately
not an error here: three callers would go red on merge. Enforcement is
gate-65's job, per app.

Derived vs declared, on two real apps

Executed the pinned dist/index.js against larpingapp and openregister's real
appinfo/info.xml from development:

larpingapp openregister
info.xml min-version="32" max-version="34" min-version="32" max-version="34"
hand-declared ["stable34","stable32","stable33"] ["stable34","stable32","stable33"]
derived branches ["stable32","stable33","stable34"] ["stable32","stable33","stable34"]
set equal yes yes
order equal no — this is the trap no
branches-max stable34 stable34
branches-max-list ["stable34"] ["stable34"]

Both apps also pass gate-65 rule 11 as shipped (hand-declared) and with the
nextcloud-test-refs line deleted (derived), so adopting derivation is a
one-line deletion that does not trade one finding for another.

Positive control — the matrix tracks info.xml

"The derived value matches what the app declares" is worth nothing on its own:
all 18 apps declare 32–34, so an action returning that array unconditionally
would satisfy it forever.

Two fixture manifests differing in one attribute are therefore checked
against each other on every PR to main
(quality-resolve-probe.ymlThe test matrix tracks info.xml):

info-32-34.xml  ->  ["stable32","stable33","stable34"]   branches-max = stable34
info-32-33.xml  ->  ["stable32","stable33"]              branches-max = stable33

stable34 drops out. The job fails if the two derivations are equal, if
stable34 survives a max-version="33", or if branches-max stops tracking
max-version.

Leg count — a matrix that collapses to one leg looks like a pass

quality-selftest.yml cannot measure this: it disables every leg, and a
matrix job whose if: is false does not expand. Measured on run
31692599960, where the skipped PHPUnit job renders as one row still carrying the
literal ${{ matrix.nextcloud-ref }} in its name — counting there would have
reported "1 PHPUnit job" whether the matrix worked or not.

So it was measured on real Actions, on a fixture/** branch calling quality.yml
twice with enable-phpunit: true. Run 31734347530, total_count: 62:

6 legs                    / PHPUnit (PHP 8.3, NC stable32, pgsql)
6 legs                    / PHPUnit (PHP 8.3, NC stable33, pgsql)
6 legs                    / PHPUnit (PHP 8.3, NC stable34, pgsql)
6 legs                    / PHPUnit (PHP 8.4, NC stable32, pgsql)
6 legs                    / PHPUnit (PHP 8.4, NC stable33, pgsql)
6 legs                    / PHPUnit (PHP 8.4, NC stable34, pgsql)
4 legs (positive control) / PHPUnit (PHP 8.3, NC stable32, pgsql)
4 legs (positive control) / PHPUnit (PHP 8.3, NC stable33, pgsql)
4 legs (positive control) / PHPUnit (PHP 8.4, NC stable32, pgsql)
4 legs (positive control) / PHPUnit (PHP 8.4, NC stable33, pgsql)

6 and 4, from two manifests differing in one attribute, with stable34
absent from the second. The legs themselves fail — .github is not a Nextcloud
app — which is expected and is not the measurement. The job list is.

That fixture workflow is NOT in this PR, and that is a finding

It was, and CodeQL scored it 12 new HIGH alerts,
actions/cache-poisoning/poisonable-step, every one of them inside quality.yml
at lines this PR never touched. main scores 0 for language:actions and PRs
#444/#446/#447 were clean, so this was the new workflow, not a pre-existing
pattern newly surfaced.

The mechanism: quality.yml needs contents: write + actions: write at
run-creation time, so a fixture/** caller hands its ~12 actions/cache
steps an entry point reachable from a non-default branch — and Actions caches are
shared with the default branch that restores them. In a repo 23 others consume at
@main, that is not worth a permanent leg counter.

The recipe for repeating the measurement, and the two ways to get it wrong, are
documented on the matrix-derivation job in quality-resolve-probe.yml. The
second way is worth repeating here: quality.yml declares
concurrency: quality-${{ github.ref }} with cancel-in-progress, so two calls
on one ref cancel each other
. Measured on run 31734110346: every job under the
control came back cancelled, its PHPUnit row still carrying the literal
${{ matrix.nextcloud-ref }} — the control measured nothing while looking like it
had run.

Also in this PR

  • gate-65 rule 11 (check_coding_standard_adoption.py) — test-matrix-not-declared
    is retired. Its premise ("a default cannot know this app's declared range")
    is obsolete: a derived matrix is not a default. Three states now:

    • derives — no override, calls the shared workflow → pass
    • declares — override must still cover the declared range and nothing outside it → unchanged
    • neither — no override and no call to the shared workflow → fail, new code test-matrix-neither-derived-nor-declared

    States 1 and 3 differ by a single uses: line, so a commented-out call is
    asserted separately (the gate-64 defect: a grep matches its string inside a
    comment).

  • nextcloud-info-path input — the path the matrix derives from. This is how
    the shared repo proves its own derivation: .github has no appinfo/info.xml,
    so without it quality-selftest.yml could only ever exercise the no-manifest
    fallback, and a derivation never executed in the repo that ships it is a
    derivation nobody has measured.

  • nextcloud-matrix is in Quality Report's needs:. It is the one entry whose
    failure does not describe the app's code — it fails when the promised range
    cannot be turned into servers to test on. The legs still run, on the fallback
    range, and still go green. That is exactly why it needs its own gate.

gate-65 rule 11, measured across all 25 callers (old checker vs new)

Real code-quality.yml + appinfo/info.xml from each repo's live branch:

repos old new
the 18 core apps clean clean — no regression
openklant, opentalk, openzaak test-matrix-not-declared clean — they call the shared workflow and now derive
n8n-nextcloud test-matrix-not-declared test-matrix-neither-derived-nor-declared
petstore, hrmq, planix test-matrix-misses-declared-versions unchanged

The n8n-nextcloud row is the rule doing its job rather than a new failure: its
code-quality.yml contains no uses: for this workflow at all — it is a
hand-rolled setup-python job — so nothing derives a range from its manifest and
nothing ever did. The old rule reported it under a description ("the matrix is
whatever the shared workflow defaults to") that was not true of that repo.

Checks

  • quality.yml resolves (job count > 0)pass, run 31734189155,
    total_count: 7 (not a 0 parse failure).
  • The test matrix tracks info.xmlpass, and its log carries the numbers:
    32-34 -> branches=['stable32','stable33','stable34'] branches-max=stable34 /
    32-33 -> branches=['stable32','stable33'] branches-max=stable33.
  • Resolve quality.yml / Nextcloud test matrixpass, log reads
    source = derived, branches = ["stable32","stable33","stable34"],
    single-server = stable34.
  • gate-65 self-test: 43 assertions, 0 failures (was 40 before this PR — the
    count did not drop). Mutation control: run against the pre-change checker,
    the four new/changed arms all go RED, so they are reachable and not vacuous.
  • Local run-helper-suites.sh: 60 PASS. One unrelated environmental refusal
    (test_gate_45_to_55_acceptance.sh, exit 2, ajv is not resolvable — the suite
    declines to run rather than emit a false verdict; the file is untouched by this PR).

Do not merge

Reported for review, per the brief.

A branch fixture/nextcloud-matrix-legs is left in place holding the withdrawn
fixture workflow and the runs that produced the leg counts. Delete it once the
numbers above have been read.

The tested range and the declared range were two hand-maintained lists, and
the fleet broke the agreement between them in BOTH directions inside a single
programme: 18 apps declared NC 32-34 while testing stable31/32, and then the
migration that fixed it replaced the list with '["stable34"]' and 16 apps
stopped testing their own declared floor. Both were detectable. Neither was
detected, because the two halves lived in two files that could be edited
independently.

quality.yml now derives the range from appinfo/info.xml with
icewind1991/nextcloud-version-matrix, pinned by SHA (8a7bac6 = v1.3.2), in a
new `nextcloud-matrix` job. `nextcloud-test-refs` survives as an explicit
override; leaving it unset — the new default — derives.

The action's `branches` output is OLDEST-FIRST. Six sites read
`fromJSON(inputs.nextcloud-test-refs)[0]` as THE single server for newman,
playwright, journeydoc-capture and the coverage guard, and newest-first was
deliberate there. Consuming `branches` positionally would have moved all four
onto stable32 silently. Those six sites now read
`needs.nextcloud-matrix.outputs.single-server`, which is the action's own
`branches-max` on the derived path — explicit, not positional.

gate-65 rule 11 changes in step. `test-matrix-not-declared` is retired: an app
that passes no override now derives, and a derived matrix is strictly better
than a declared one. It is replaced by
`test-matrix-neither-derived-nor-declared`, which fires only when an app
neither declares a matrix nor calls the shared workflow. An override that
disagrees with info.xml still fails, both directions, exactly as before.
…rency group cancelled the control

Measured on run 31734110346: quality.yml declares `concurrency: quality-${{ github.ref }}`
with cancel-in-progress, so two calls on one ref share the group and the second
cancels the first. Every job under `4 legs` came back cancelled and its PHPUnit
row still carried the literal ${{ matrix.nextcloud-ref }} — the control measured
nothing while looking like it had run.
…isoning regression

CodeQL scored 12 NEW HIGH alerts against this PR,
`actions/cache-poisoning/poisonable-step`, all inside quality.yml and none of
them in code this PR wrote. main scores 0 for `language:actions` and PRs #444,
#446 and #447 were clean, so this was the new workflow and not a pre-existing
pattern newly surfaced.

The mechanism: quality.yml needs `contents: write` + `actions: write` at
run-CREATION time, so a `fixture/**` caller hands its ~12 `actions/cache` steps
an entry point reachable from a non-default branch — and Actions caches are
shared with the default branch that later restores them. In a repository 23
others consume at @main, that is not worth a permanent leg counter.

The measurement it existed to produce is already taken, on run 31734347530:
6 legs from info-32-34.xml, 4 from info-32-33.xml, stable34 absent from the
second. The recipe for repeating it — and the two ways to get it wrong — is now
documented on the `matrix-derivation` job in quality-resolve-probe.yml.
@rubenvdlinde
rubenvdlinde merged commit a296384 into main Aug 13, 2026
36 checks passed
@rubenvdlinde
rubenvdlinde deleted the feat/derive-nextcloud-matrix-from-info-xml branch August 13, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant