Skip to content

Release: merge development into beta for the fleet-wide beta checkup - #279

Merged
rubenvdlinde merged 738 commits into
betafrom
sync/dev-to-beta-20260820
Aug 20, 2026
Merged

Release: merge development into beta for the fleet-wide beta checkup#279
rubenvdlinde merged 738 commits into
betafrom
sync/dev-to-beta-20260820

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Brings beta up to date with development for tonight's fleet-wide beta checkup (team decision, 2026-08-20), so tomorrow's review runs against current code on the online beta environment.

Why this PR and not the auto sync PR: the automatic development -> beta PR was CONFLICTING and could not merge. This branch is that same merge, with the conflict resolved.

Resolution policy: for files development actively maintains, development's version wins — that is what a release-channel sync means. Beta-only files that did not conflict are preserved untouched.

The per-conflict reasoning is in the merge commit message.

Also carried to beta: the converged release.yml (beta's pre-convergence workflow has no workflow_dispatch trigger), and the app signing path that started working today once the NEXTCLOUD_SIGNING_KEY/CERT secrets landed — the first successful releases since 2026-08-17.

rubenvdlinde and others added 30 commits August 3, 2026 12:05
…-> 2026-08-01) (#103)

The lockfile pinned roave/security-advisories to a commit from 2026-03-20,
so the metapackage's conflict rules — and therefore the protection
against installing known-vulnerable dependency versions — were frozen
at that date. Refreshed to the 2026-08-01 tip.

composer audit --locked: clean before and after.
Jobs without timeout-minutes fall back to GitHub's 360-minute default, so
a hung runner burns six hours of Actions minutes before it is reaped.

Bounds are derived from observed run durations and left deliberately loose:
a timeout that fires under normal contention is worse than no timeout,
because it turns a slow run into a phantom defect.

Jobs that only call a reusable workflow (job-level `uses:`) are untouched --
they inherit their bound from the called workflow.
…he app-local proxy (ADR-080)

StoreController now extends AppHost's GenericStoreControllerBase and inherits
search() plus the SSRF-guarded, redirect-refusing, token-private fetch.
RemoteTemplateStoreService (331 lines) and its test file are deleted; the
behaviour and the SSRF controls live in OpenRegister's GenericStoreServiceTest.

Install stays here, and only install — cloning a template into a local virtual
app is OpenBuild-specific and differently authorized from the connector-adapter
and agent-template installs in other apps.

Three things the tests caught that review would not have:

1. The suite could not load StoreController at all — `extends` is resolved by
   the AUTOLOADER, not the container, and the OR AppHost classes are stubbed
   here rather than autoloaded. Added stubs for GenericStoreService,
   StoreDescriptor and GenericStoreControllerBase. This is the same mechanism
   by which a missing sibling app 500s EVERY route in production, which is why
   ADR-080 restricts subclassing to apps declaring openregister a hard <app>
   dependency — this app qualifies (8 controllers already type-hint OR classes).

2. The search test's `getParam` mock returned one value for ANY key, so `kind`
   silently received the query string. Made it key-aware; the action reads `q`
   and `kind` separately.

3. tests/stubs/openregister-stubs.php defines a NO-OP SecurityService, because
   the real guard does DNS lookups that fail for .test fixture hostnames. Any
   "SSRF negative control" written against that stub passes regardless of what
   the guard does. Corrected the docblock to say so, and to point at the
   OpenRegister suite where the real guard is exercised. The new store stub is
   deliberately non-behaving for the same reason.

743/743 unit tests, 7/7 on the store controller.
StoreController is a plain Controller injecting GenericStoreService, with its
own ~30-line search() action, rather than subclassing a cross-app base. The
inheritance broke phpstan ('extends unknown class', which it refuses to let you
ignore), psalm, and the unit suite's class loading — one stub entry now covers
the injected type-hint instead.

phpstan OK, psalm no errors, 743/743 tests.
…settings

refactor(store): consume OpenRegister's GenericStoreService, delete the app-local proxy (ADR-080)
…110)

Bumps @conduction/nextcloud-vue 2.1.0-vue3.16 -> 3.0.0-vue3.2, pinned
exactly.

The major bump is narrow. Diffing the packed tarballs, 3.0.0-vue3.2
removes exactly three symbols against every 2.1.0-vue3.x baseline
(.13/.15/.16/.17/.19): CnFlowCanvas, CnEditFlowsModal and
CnFlowCanvasModal — the old flow-authoring surface, which moved into
OpenRegister's one flow store. From .16 it also ADDS CnFlowDetail,
CnFlowEditModal and CnObjectAccessTab, and exports useFlowStore from the
barrel. Nothing else in src/ changed: the only other edited file is
CnOpenBuildEditButton, which internally swapped the two removed dialogs
for CnFlowEditModal.

No OpenBuild source imported any of the three removed symbols, so the
bump needs no code change here. Verified in the built bundle rather than
by assertion: after the rebuild neither removed symbol appears in js/,
while CnFlowEditModal does — the positive control for that grep.

Separately, four peers nc-vue has declared since at least 2.1.0-vue3.13
were never declared by this app and were resolving only by luck of
hoisting: @vueuse/core, axe-core, dexie and marked. Declared them at the
versions OpenRegister and OpenConnector already use.

Verified from the lockfile, not package.json: nextcloud-vue is exactly
3.0.0-vue3.2, and vue3-apexcharts resolves to 1.8.0 — below the 1.9.0
that turned proprietary and cannot be sublicensed under EUPL-1.2.
@nextcloud/l10n resolves to 3.4.1, so this app is not exposed to the v2
boot-killer where nc-vue's pre-bundled dialogs call the v3-only
getGettextBuilder().detectLanguage().

Gates, all after a full rm -rf of node_modules and package-lock.json:
npm install (npm 11) then npm ci (npm 10) both exit 0 on a 780 KB /
1717-package lockfile, eslint exit 0, 1364 unit tests in 140 files
passing, and webpack exit 0 with only the pre-existing asset-size
warnings. The build was run with USE_LOCAL_LIB=false so it measured the
published package: this repo sits next to a nextcloud-vue checkout, and
webpack.config.js aliases the sibling by default, which would have
measured the wrong code entirely.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…104)

* build(quality): run the Hydra gates as part of composer check:strict

openbuild is the pilot for conduction/hydra-gates. The gates have until now only
run inside hydra's own containers, which means an agent finishing a task here
could report done without any of the 61 mechanical gates having looked at its
diff. Wiring them into check:strict makes "gates pass" part of the definition of
done in the same command everything else already runs.

The gates are diff-scoped per ADR-020, so this does NOT import openbuild's
inherited debt. A full-repo run of this tree fails 16 gates today (img-alt,
button-name, table-headers, spec-anchor-existence and friends); scoped to a PR's
own diff, only what that PR touched is enforced. `composer gates:full` is
available for the audit view, and is deliberately not what check:strict runs.

check:strict keeps its 0/1 contract, but the gate exit code is preserved
separately and reported, because it carries the failure COUNT and flows route on
it. 99 is distinguished from a gate failure in the summary: it means the gates
could not run at all, which is a configuration error and not a clean tree.

The dependency is declared against a `path` repository at ../hydra, which is the
fleet's checkout layout. That is the part of this change that is provisional —
see the PR description; how hydra-gates should be DISTRIBUTED (private Packagist,
a Satis mirror, or a split public repo) is an open decision, and a path
repository is the shape that is verifiable today without one.

* build(quality): lock conduction/hydra-gates at dev stability

A path repository derives its version from the checked-out branch, so the
default minimum-stability of 'stable' rejects a '*' constraint outright:
'found conduction/hydra-gates[dev-main, ...] but it does not match your
minimum-stability'. '@dev' is the constraint that resolves, and it keeps
working once the package is eventually tagged.

* build(quality): resolve hydra-gates from the public package, not a path repo

The `path` repository at `../hydra` is what blocked this PR. It works on a
developer machine and fails in CI with `Source path "../hydra" is not found`,
because the fleet's sibling-checkout layout does not exist in a CI job and
hydra is private, so no runner can fetch it.

conduction/hydra-gates now lives in ConductionNL/.github, which is PUBLIC and
already owns the shared workflows (ConductionNL/.github#131). Pointing at it
needs no credentials in this repo or on any runner.

- repositories: `path ../hydra` -> `vcs https://github.com/ConductionNL/.github.git`
  with `"no-api": true`, so composer clones over git instead of the GitHub API
  and a rate-limited unauthenticated runner cannot become a failed install.
- require-dev: `@dev` -> `^1.0`, resolving to the v1.0.0 tag. That also drops
  the dev-stability requirement the previous commit needed, because a path
  repository derives its version from the checked-out branch and a tag does not.
- composer.lock pins commit fdad2546f2ac68aa64be5fecef898784c1847538.

Nothing else changes. `composer gates`, `composer gates:full` and the gate
handling inside `check:strict` are untouched, including the part that captures
the gate exit code separately: it carries the FAILURE COUNT, and 99 ("could not
run at all") is still reported distinctly from a gate failure so a
configuration error can never read as a clean tree.

Also merged origin/development in. The branch predated the fleet-wide
timeout-minutes work, so it was carrying a silent revert of the bounds on
exporter-e2e.yml and pull-request-lint-check.yaml.

* ci(quality): actually run the gates in CI, not just in a local composer script

Wiring the gates into `composer check:strict` was a local-only change. Nothing
in this repository's CI invokes check:strict — no workflow does, verified by
grepping .github/workflows — so this PR would have merged green with the 61
gates never having executed on any diff. A gate that runs only on a developer's
machine is not a gate, and a green that never ran it is the failure mode the
gates exist to catch.

The shared quality workflow now ships a `hydra-gates` job (ConductionNL/.github#131),
opt-in per repo and defaulting to off. openbuild is the pilot, so it opts in.

`hydra-gates-ref` is pinned to the v1.0.0 tag rather than tracking `main`, so a
change to the gates cannot move this repo's verdict without a commit here to
move the pin.

The job resolves the diff base from the PR's real target branch, checks out the
app with fetch-depth: 0 (a shallow clone has no base to diff against), and
reports exit 99 — "could not run at all" — distinctly from "N gates failed".

* build(quality): bump hydra-gates to v1.0.1 for the gate-4 audit fix

The first real CI run of the gates on this PR failed on gate-4:
`composer-audit: FAIL — CVEs or advisories`. There are no CVEs — auditing this
repo's lock properly reports "No security vulnerability advisories found."

`composer audit` with no vendor/ present does not audit the lock. On the
runner's composer it errors and exits non-zero, which gate-4 reported as a
security finding. On composer 2.7.x the same situation prints "No packages -
skipping audit" and exits 0 — a silent fail-open that passes having audited
nothing.

Fixed in the package (ConductionNL/.github#135, released as v1.0.1): gate-4
audits `--locked`, treats an exit 0 that audited no packages as a failure, and
reports "could not run" in those words rather than as a CVE finding.

Both the workflow pin and composer.lock move to v1.0.1 so the two paths into
the gates cannot disagree about which version this repo is on.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Picks up the ADR-079 admin-settings link-out: the gear foldout's 'Admin
settings' entry becomes an admin-gated LINK to /settings/admin/<app> instead of
a modal, and ADR-079 Step 2's organisation credential broker home on
CnAdminSettingsShell.

Verified the feature is genuinely in the published tarball rather than trusting
a green Release run: vue3.2 ships 0 files containing showAdminSettingsLink and 9
containing the removed cnOpenAdminSettings; vue3.4 ships the new API.

Live-verified end-to-end on openconnector (ConductionNL/integriq#1136): the
foldout renders Settings > Personal settings + Admin settings ->
/settings/admin/openconnector, which returns 401 for an anonymous caller.

Lockfile updated with --package-lock-only; CI builds and runs this app's own
e2e against it.
…ent (#113)

The comment introduced in #104 read "the 61 Hydra mechanical gates". That
number was never corrected here to a newer one on purpose — correcting it
would only reset the clock on the same failure mode.

The count has been wrong in both directions: the prose said 61 while only 59
gates actually reported, because gate-33 (axe-core) and gate-24 skip silently.
A hard-coded digit sitting in a comment is exactly how that 61/59 gap survived
unnoticed — nothing in CI compares the prose to the runner, so the number can
only ever drift, and a stale number overstates the coverage this repo has.

So the comment now carries no number at all and points at the composer package
`conduction/hydra-gates` (ConductionNL/.github, hydra-gates/) as the single
source of truth for which gates exist and how many report.

Comment-only change: no workflow input, pin or behaviour is touched.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…ion` (#116)

GitHub names a reusable-workflow status context `<caller-job-id> / <called-job-name>`, so this
caller reported as `check / check-branch` while the org ruleset requires
`branch-protection / check-branch`. development is the head branch for the
`development -> beta` release PRs, so its tip decides the context name on those PRs.
…stallable standalone apps

RESCUED FROM CODEBERG. ExportService::buildScaffoldMap() landed on
codeberg.org/Conduction/openbuild as 4a47d797 (2026-07-26) and never reached
GitHub. Codeberg is being retired, so it would have been destroyed with the
remote. Searched all 230 origin refs for 'buildScaffoldMap' in
lib/Service/ExportService.php — zero hits.

Three of the four Codeberg-only openbuild commits DID land on GitHub, via
merge 001ea41 'import Codeberg feature line onto GitHub'. Blob-identity
checked at both tips for PageDesignerHost.vue, BuilderHost.vue,
AgentsController.php, GeneratedDocumentController.php, the three listeners and
CHANGELOG.md — all IDENTICAL. This method is what that import missed.

The port is purely additive — 73 insertions, 0 deletions across the three
files — so GitHub's copies were strict subsets and taking the Codeberg blobs
reproduces them exactly. All six helpers the method calls (prepareScratchDir,
copyTemplate, resolvePlaceholders, bundleDataRegisterSchemas, listFilesSorted,
rrmdir) are verified present on GitHub's ExportService.

ONE DELIBERATE CHANGE from the Codeberg original. Its docblock read:

  @SPEC openspec/changes/federated-config-sharing/specs/federated-config-sharing/spec.md

That path does not exist on GitHub — and does not exist on the CODEBERG tip
either. It was a dangling anchor from the moment it was written, and would
fail gate-58 (spec-anchor-existence). Re-pointed at the github-app-sync spec,
which is the change this method actually serves (GitHubAppSyncService folds
the scaffold into the config-set repo publish) and which does exist here.
The bump itself was always fine; the lockfile was written by the wrong npm.

CI runs Node 20.20.2 / npm 10.8.2. My workstation runs Node 22 / npm 11.13.0,
and an npm-11-written lockfile is REJECTED by npm 10:

    npm error code EUSAGE
    `npm ci` can only install packages when your package.json and
    package-lock.json are in sync.
    Missing: pinia@4.0.2 from lock file
    Missing: vite@8.2.0 from lock file

npm 11 prunes entries npm 10 still requires — regenerating under Node 20 adds
~1350 lines straight back. Verified `npm ci --dry-run` rc=0 under node:20
before pushing.

Worth noting the misdiagnosis this corrects: `npm ci` passed locally under
npm 11 on the same lockfile, so "works on my machine" was true and useless. The
package versions were never wrong (pinia 2.3.1 / vite 5.4.21, identical to what
development already locked) — only the lockfile FORMAT was.
chore(deps): @conduction/nextcloud-vue 3.0.0-vue3.4
… calls

phpmd's TooManyPublicMethods fired on this class the moment
buildScaffoldMap() was rescued, and the finding was pointing at
something real: of 12 public methods, only two — generateAppZip() and
scratchTreeDir() — had a production caller (both RunExportJob). The
other nine were public purely so the two test files could reach them.

Nine methods are now private: copyTemplate, resolvePlaceholders,
packageZip, listFilesSorted, isBinary, prepareScratchDir,
getOrCreateAppDataDir, rrmdir, bundleDataRegisterSchemas.

Both test files now drive the real entry points instead. Every
behaviour the old tests asserted directly on a step is now asserted
through its effect on what generateAppZip() / buildScaffoldMap()
produce — placeholder resolution, ASCII entry ordering, the snapshot
helper-file skip, data-register bundling, scratch-tree cleanup — plus
three assertions the old tests did not make at all. Suite: 743 -> 746
tests, 2245 -> 2342 assertions.

No baseline entry was added: phpmd is now clean on this class, and the
report set-diffs empty against development.
…ldmap

feat(publish): rescue buildScaffoldMap() — openbuild-app repos are installable standalone apps
Codeberg is retired; ConductionNL is GitHub-only. GitHub Actions never
executes .forgejo/**, so these workflows contributed zero status checks.

Deleted 6 of 8, each with a GitHub counterpart (code-quality.yml,
documentation.yml, release-beta.yml, release-stable.yml, exporter-e2e.yml).

KEPT app-tests.yml + tests.yml -- tests.yml carries the only definition of
the l10n extraction gate and the coverage ratchets (no .github/workflows/
l10n.yml here, enable-coverage-guard unset), and
openspec/changes/openbuild-nl-locale-parity targets that file.

Also repointed the .forgejo reference in tests/e2e/visual/README.md.
… button (#121)

`@nextcloud/vue` 9 renamed NcButton's visual prop `type` -> `variant` and
repurposed `type` as the NATIVE button type, with `default: "button"`.
`nativeType` was removed outright — it was the v8 name. This repo declares
^9.5.0 but the lockfile resolves 9.9.0 (the caret does not move an
already-resolved version, so the lockfile is what ships).

The settings Save button passed BOTH v8 spellings
(`type="primary" native-type="submit"`). Precisely what that does:

  • `native-type` is undeclared on v9, so it is inert.
  • `type="primary"` is consumed as the NATIVE button type. "primary" is
    not a valid one, and HTML's invalid-value default for <button type> is
    the Submit Button state — so the form DOES still save.
  • but `variant` never receives "primary", so it keeps its "secondary"
    default and the button renders unstyled.

So this was mis-styled rather than dead. Fixed anyway: the markup is
invalid and the shape is one upstream change away from breaking outright.

Verified in place: the button sits inside `<form @submit.prevent="save">`.

Behaviour is proven in openconnector#1140, which mounts the real NcButton
9.9.0 and pins all three spellings with negative controls.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Remove the 'if [ -f vendor/bin/phpunit ]' guard that made test:unit/test:all always exit 0. Measured: 746 tests now run and the suite fails when an assertion is broken.
chore(deps): @conduction/nextcloud-vue 3.0.0-vue3.6
…pping (#123)

* ci(quality): enable the Code Quality gates this repo was silently skipping

A skipped job and a passing job are indistinguishable in the Quality
Report. Every gate turned on here reported 'skipped' in every run.

Each newly-enabled leg was measured against this tree BEFORE being
enabled; the results are in the PR description. Legs that were measured
failing are enabled anyway - the defects are pre-existing, and the only
thing that changed is that CI can now see them.

Journeydoc Capture and enable-axe are deliberately NOT enabled.

* ci(quality): remove duplicate with: keys; respect documented newman opt-outs

A second key in the same with: block is accepted last-one-wins by every
YAML parser involved, so the file reads as configured while the value is
decided somewhere else in it.

Where enable-newman: false already carried a specific, still-live reason
(OR React/Async await + SQLite REGEXP; ZGW API incomplete; collections
hard-coding non-CI URLs and unseeded registers), it is left off and the
reason is quoted rather than overridden. Where it was a bare line with no
comment, the stale line is removed instead of shadowed.
…an install (#125)

lib/Settings/openbuild_register.json declared no components.registers section.
OpenRegister's ImportHandler creates a Register row from that key and nowhere
else on the main/beta lines (ImportHandler.php:1514) -- so a clean install
created 15 schemas and ZERO registers, and then skipped all 7 seed objects,
whose @self.register is resolved through the registersMap that section
populates. Every GET /api/objects/openbuild/<schema> 404s with
"Register not found: 'openbuild'".

Verified on a disposable Nextcloud 34 + PostgreSQL + OpenRegister main
(0.2.19), importing the merged register document (base + register.d/*.json,
merged exactly as SettingsService::deepMergeConfig does) into a VIRGIN
OpenRegister with zero registers:

  configurations/import -> HTTP 200
  register 'openbuild'  -> present, id/version 0.9.0
  15/15 expected schema slugs present AND bound into the register
  15/15 GET /api/objects/openbuild/<schema> -> HTTP 200

The slug is derived, not invented: lib/ and src/ address
`objects/openbuild/<schema>` in 41 places and all 7 seed objects carry
`@self.register: "openbuild"`.

The schema list is by SLUG, not by components.schemas key -- and for openbuild
every one of the 15 differs (`Application` -> `application`,
`ApplicationTemplate` -> `application-template`, `HelloMessage` ->
`hello-message`, `TestCase` -> `rule-test-case`, ...). ImportHandler keys its
schemasMap by $schema->getSlug() (ImportHandler.php:1397/1477), so a register
listing the KEYS binds ZERO schemas while still looking correctly declared;
that is exactly what the first draft of this change did, and only the live
check caught it.

info.version 0.8.0 -> 0.9.0 so OpenRegister's version-gated importFromApp
actually re-imports.

appinfo/info.xml already lists the register-importing InitializeSettings step
under <install> as well as <post-migration>, so openbuild does NOT have the
second, independent gap where the import is post-migration-only and therefore
never runs on a fresh install (Installer::installAppLastSteps() guards both
migration blocks with `if ($previousVersion !== '')`).

NOTE for reviewers: openbuild's appinfo/routes.php references
OCA\OpenRegister\AppHost\Routes, which exists only on OpenRegister
development. With OpenRegister main installed, enabling openbuild makes EVERY
route on the instance return 500 ("Class OCA\OpenRegister\AppHost\Routes not
found") -- NC's router reflects every controller during route matching. That
is pre-existing, unrelated to this change, and is why openbuild's CI pins
OpenRegister development; it is reported separately.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* chore(deps): move to @conduction/nextcloud-vue 2.2.0-vue3.1

The 3.0.0-vue3.* line is being withdrawn from npm. The major was cut from a
real BREAKING CHANGE footer (the retired action-list flow editors), but it
applied to a prerelease channel only our own apps consume, so the line is
resumed at 2.x rather than carried forward. 2.2.0-vue3.1 is a superset of
3.0.0-vue3.6 — it additionally carries the recovered Vue 2 -> Vue 3 component
conversion and the CnGraphCanvas port/loop work. Despite the lower version
number this is not a downgrade in content.

The lockfile is regenerated with npm 10.8.2, matching CI's node 20 toolchain.
Local npm 11 prunes optional entries that do not apply to the current platform,
which makes CI's `npm ci` fail with "Missing: ... from lock file"; running
`npm ci` locally does not reproduce it because npm 11 accepts its own lockfile.

Verified: `npx npm@10.8.2 ci --dry-run` exits 0, and `USE_LOCAL_LIB=false
npm run build` exits 0 with no unresolved modules. The USE_LOCAL_LIB=false is
load-bearing — this repo's webpack aliases @conduction/nextcloud-vue to a
sibling ../nextcloud-vue/src checkout when one exists, so a plain build can
silently compile the sibling's source instead of the package under test.

* fix(l10n): add the 111 source strings missing from en.json

test:l10n was already failing on development before this branch: 111 keys are
used via t('openbuild', '...') in source but absent from l10n/en.json, so the
gate could not pass on any PR.

Generated with the repo's own remedy, `node tests/l10n/check-l10n.js --write`,
which extracts the used strings with key === English source. All 111 additions
are identity-mapped (verified), so this adds no translation content and changes
no rendered English text — it only makes the keys declarable for translators.

This check has no locale-parity stage, so en.json is the whole requirement here
and test:l10n now exits 0 (1288 -> 1399 keys).

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
* chore(deps): move to @conduction/nextcloud-vue 2.2.0-vue3.3

Picks up the two releases that landed after 2.2.0-vue3.1:

  2.2.0-vue3.2  four dashboard defects — date-range chip shows its dates and
                calendar-aligned presets, a dangling labelResolve no longer
                renders a raw UUID, and the table's "View all" pins to the
                bottom instead of scrolling away
  2.2.0-vue3.3  gridstack's stylesheet now ships with the library that
                requires it; CnFormDialog splits over-long schema descriptions
                behind an info popover; CnContextMenu closes again on outside
                press and stops hijacking every popper with a cursor transform

Lockfile regenerated with npm 10.8.2 to match CI's node 20 toolchain — local
npm 11 prunes optional entries that do not apply to the current platform, which
makes CI's `npm ci` fail with "Missing: ... from lock file". Running `npm ci`
locally does not reproduce it, because npm 11 accepts its own lockfile.

Verified: `npx npm@10.8.2 ci --dry-run` exits 0, and `USE_LOCAL_LIB=false
npm run build` exits 0 with no unresolved modules and no reference to a sibling
nextcloud-vue checkout. USE_LOCAL_LIB=false is load-bearing: webpack aliases
@conduction/nextcloud-vue to ../nextcloud-vue/src when that sibling exists, so
a plain build can silently compile the sibling instead of the package under test.

* fix(deps): restore the optional lockfile entries npm 11 pruned

The verification build ran `npm install` under local npm 11 AFTER the lockfile
had been regenerated with npm 10.8.2, which silently re-pruned the optional
entries that do not apply to this platform — per-arch esbuild/rolldown/
lightningcss binaries and @nextcloud/vue's optional pinia and vite.

CI runs npm 10.8.2, whose `npm ci` requires those entries, so it failed at
Install dependencies and took every dependent job with it.

Regenerated with `npx npm@10.8.2 install --package-lock-only` and this time
nothing runs npm 11 against it afterwards. Verified with
`npx npm@10.8.2 ci --dry-run` (exit 0).

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
* ci(quality): move the hydra-gates pin v1.0.1 -> v1.2.0

v1.0.1's gate-5 (route-auth) could not tell "this routed method has no auth
attribute" from "I could not resolve the controller class", and reported both
as the former. It also read route names through `'[a-z_]+#...'`, so every
camelCase route slug was invisible to it in either direction, and its 20-line
attribute lookback could borrow the PREVIOUS method's attribute.

Fixed in ConductionNL/.github#162 (closing #153). v1.2.0 also carries the
runner's COVERAGE accounting, which v1.0.1 does not have at all: v1.0.1's
summary printed only "N gate(s) failed", so a gate that never ran was
indistinguishable from one that passed.

EXPECT NEW FINDINGS. gate-5 now sees code it never saw. Red is the honest
outcome of a measurement that previously was not taken; nothing here is
baselined or suppressed to avoid it.

* ci(quality): advance the hydra-gates pin v1.2.0 -> v1.3.0

v1.2.0 added the COVERAGE accounting but not the declarations that accounting
asks for. v1.0.1/v1.2.0 have no `_skip` vocabulary, so a gate whose
prerequisite is absent emits nothing and require-full-coverage (default ON)
counts it as DID NOT RUN. v1.3.0 ships 36 not-applicable declarations;
measured on doriath PR #160, Hydra Gates went failure -> success on the pin
alone. Lands this repo on the same pin as the rest of the fleet.
…1.2 (#134)

Three test files declared SPDX-License-Identifier: AGPL-3.0-or-later while
every other licence signal in the repo (LICENSE, composer.json, package.json,
appinfo/info.xml and every lib/**.php @license tag) says EUPL-1.2.

- tests/e2e/docs-screenshots.spec.ts
- tests/e2e/visual/_visual-helpers.ts
- tests/e2e/visual/openbuild.visual.spec.ts

NOT changed: lib/Resources/template/appinfo/info.xml still says
<licence>agpl</licence>. That file is inside the vendored nextcloud-app-template
snapshot, which docs/releasing.md says must be refreshed wholesale by rsync and
never hand-edited, and whose .snapshot-meta.json records the exact upstream
commit it came from. Upstream already says EUPL-1.2, so the correct fix is a
snapshot refresh, not an edit here. See the PR body.

Header-only change; PHPUnit (746 tests) and vitest (1364 tests) are identical
before and after. gate-28: PASS -> PASS.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
… inert exclude-pattern (#129)

* chore(phpmd): burn down 46 baselined findings and fix the inert exclude-pattern

Part of ConductionNL/.github#155 (fleet PHPMD suppression audit).

`composer phpmd` reports 0 today. With phpmd.baseline.xml deleted it reports
195. The gate is live: openbuild's Code Quality workflow calls the shared
ConductionNL/.github quality.yml, where `enable-phpmd` defaults to true, so the
phpmd leg genuinely runs on every push and PR — the baseline is hiding findings
from a check that really executes.

Two things worth recording about the baseline file itself:

  * Its 174 entries suppress 195 findings, because a PHPMD baseline entry is
    scoped to a (rule, file) pair — optionally a method — never to a line. A
    class-scoped entry is therefore an open licence: every FUTURE violation of
    that rule in that file is silently suppressed too. "174 entries" is not
    "174 known problems", and the shortfall is granularity, not staleness.
  * PHPMD auto-discovers phpmd.baseline.xml from the working directory.
    Dropping --baseline-file from the composer script changes nothing; only
    deleting the file disables it.

What this commit retires (measured, PHPMD 2.15.0 / PHP 8.4.22, baseline
deleted both times, --exclude '*/Resources/template/*' on every leg): 195 -> 149.

  * ElseExpression 27 -> 1.
  * MissingImport 3 -> 0 (fully-qualified `new \RuntimeException` in
    AppOverrideService).
  * ShortVariable 9 -> 0 ($m/$ca/$cb/$i -> $matches/$condA/$condB/$seq/$cursor).
  * LongVariable 5 -> 0; the two renamed constructor-promoted properties are
    also renamed at their named-argument call sites in the unit tests.
  * UnusedFormalParameter 3 -> 1. Two were genuinely dead and are REMOVED, with
    their call sites updated: AppOverrideService::userVersionSlug($appId) and
    AppRepoParser::decodeChannelEntry($path).
  * CyclomaticComplexity 41 -> 40, from the two consolidating extractions below.

The ElseExpression work is behaviour-preserving throughout: initialise-then-
override where both branches were pure, guard-and-return where they were not,
`continue` inside loops, and extractions where both arms had to write so exactly
one could run. No ternaries were introduced.

Two traps from the brief fired for real, and re-measuring after every rule is
what caught them:

  1. THE EXTRACTION TIPS THE CLASS. Three extractions from AppOverrideService
     pushed BooleanArgumentFlag 13->14, ExcessiveClassLength 7->8 and
     TooManyMethods 4->5 — the class sits exactly at those thresholds. All three
     were restructured to need no new method (a `continue` guard in
     deepMergeDelta, an early return in upsert), and the one that genuinely
     could not be — upsertUserDelta, where both arms write a different object —
     was LEFT AS AN ELSE rather than traded for three other findings. That is
     the single remaining ElseExpression.

  2. REMOVING AN `else` ADDS COMPLEXITY. Converting the two elevate-or-not
     if/elses in MigrateToVersionedModel::migrateOne() gave it a NEW
     NPathComplexity finding (336), and converting the two update-or-create
     if/elses in AutomationCompilerService::applyRuleSet() took its
     CyclomaticComplexity from 9 to 11. Both were fixed STRUCTURALLY rather than
     by relaxing anything: each pair of duplicated branches was folded into one
     guarded helper (`runElevated()`, `saveOrUpdate()`), which removes the elses
     AND lowers the host method's complexity below where it started. A third,
     the state chain in run(), keeps its `else if` and only drops the bare
     `else` — PHPMD flags the bare `else` only, so that costs no extra path,
     whereas two independent ifs would have doubled an already-flagged NPath.

Also applies the fleet-standard scoped-ruleset shape. The
`<exclude-pattern>*Migration*</exclude-pattern>` nested inside the
UnusedFormalParameter `<rule>` was INERT — PHPMD 2.15 honours exclude-pattern
only as a direct child of `<ruleset>`. The rule now lives alone in
phpmd-unusedparams.xml with a top-level exclusion, run as a second leg with
neither leg able to short-circuit the other, and `--exclude
'*/Resources/template/*'` preserved on BOTH legs so the analysed file set is
unchanged. Stated plainly: openbuild has no lib/Migration directory, so this
retires ZERO findings here. It is adopted for fleet uniformity.

phpmd.baseline.xml is RETAINED. 149 findings remain and deleting it would
redden a gate that really runs. What remains, and why:

  * CyclomaticComplexity 40 / NPathComplexity 25 / ExcessiveClassComplexity 23 /
    ExcessiveMethodLength 11 / ExcessiveClassLength 7 — genuine debt in the
    automation-compiler, copilot and app-override services.
  * CouplingBetweenObjects 15, TooManyMethods 4, ExcessiveParameterList 3,
    TooManyPublicMethods 1 — class-level; need collaborator extraction, which
    AppOverrideService's thresholds show cannot be done piecemeal.
  * BooleanArgumentFlag 13 — genuine debt; each is an API change.
  * StaticAccess 4 — `\OCP\Server::get()` service-locator calls in three broker
    paths plus OpenRegister's `Bootstrap::register` in AppInfo\Application; the
    Bootstrap one is a bootstrap-time static by design.
  * ElseExpression 1 — AppOverrideService::upsertUserDelta, see trap 1 above.
  * UnusedFormalParameter 1 — INTERFACE-MANDATED. SeedHelloWorldFixture::execute()
    implements Symfony\Component\Console\Command\Command::execute(InputInterface
    $input, OutputInterface $output). `$input` is unused but cannot be dropped.
  * LongClassName 1 — UnsupportedAutomationCombinationException. Deliberately
    NOT renamed: the name is a normative identifier in
    openspec/specs/automation-designer/spec.md and in three archived changes.
    Renaming it in code alone would desynchronise the spec, and rewriting
    archived changes is out of scope.

Verified: phpunit 746 tests / 2342 assertions before AND after, both green.
phpcs exits 0 (the renames left stale docblock alignment; fixed with the repo's
own phpcbf, 24 errors, no remaining errors). psalm and phpstan both exit 0.
Both phpmd legs exit 0 with the baseline in place, so CI stays green.

No suppression, baseline entry, threshold change or skip was added anywhere.

* chore(phpmd): keep this PR single-purpose — revert the else-fixes in 6 files

Touching these six files pulls three PRE-EXISTING hydra-gate failures into the
diff-scoped gate run, all of them in code this PR did not write:

  * gate-2 forbidden-patterns — the deliberate, documented error_log() in
    AppInfo\Application::register(), which carries its own phpcs:ignore and a
    comment explaining that no PSR logger exists that early. Removing an else
    necessarily moves those lines into the diff. The call must stay; the gate
    has no per-call waiver; so the file must stay untouched.
  * gate-46 spec-anchor-existence — 17 dead @SPEC targets across
    ExportsController, AutomationsController, AppInfo\Application,
    RuleActionDispatcher, AutomationCompilerService and TemplateSeedService.
    They point at openspec/changes/automation-designer/... which was ARCHIVED
    to openspec/changes/archive/2026-07-11-automation-designer/... and at
    #req-autd-NNN anchors that exist in NO version of the spec, canonical or
    archived — the canonical spec uses '### Requirement: <prose>' headings.
    Repairing them means deciding, per tag, which Requirement each method now
    implements. That is spec-traceability work for the automation-designer
    owner; guessing it here would encode WRONG links, which is worse than a
    dead one.
  * gate-49 controller-exception-translation —
    AutomationsController::recompileAndRespond() lacks a try/catch or @throws.

The gates are FILE-scoped (_filter_grep_by_scope keys on the path), so any edit
to one of these files puts all of its pre-existing findings in scope. Reverting
is the only way to keep a PHPMD suppression burn-down from silently becoming a
spec-metadata rewrite.

Cost, measured: openbuild goes 149 -> 161 with the baseline deleted (still
195 -> 161). Specifically this gives back 11 ElseExpression, 1 ShortVariable and
1 LongVariable, and drops the AutomationCompilerService::saveOrUpdate()
consolidation, so CyclomaticComplexity returns to 41.

Follow-up filed in the PR body: those three gate failures are real and now
precisely diagnosed, but they belong to a separate change.

* fix(phpmd): narrow the UFP exclude to the app's own lib/ directory

Positive-controlled (PHPMD 2.15, PHP 8.4, absolute paths): with */lib/X/* a
probe in lib/Service/X/ is reported; with */X/* it is silently swallowed. Both
correctly exclude the app's own directory, so the narrow form is strictly safer.
Matches the fleet-canonical shape in nextcloud-app-template #125, doriath #157,
larpingapp #263 and planix #315.
#135)

The embedded template snapshot's appinfo/info.xml hardcoded
`<licence>agpl</licence>` while the very same file's description read "Free
and open source under the EUPL-1.2 license". Every app OpenBuild has ever
generated inherited that contradiction, and nothing failed.

The `{{license}}` token was already wired end to end — ExportJobService ->
RunExportJob -> PlaceholderResolver, defaulting to EUPL-1.2 at all three
layers — and reached exactly one file, src/manifest.json. info.xml never
consumed it. `<licence>{{license}}</licence>` closes that, and as a side
effect a caller who picks a licence now gets the licence they picked.

EUPL-1.2 is the schema-correct spelling, not a preference: the Nextcloud
appstore's own info.xsd enumerates `EUPL-1.2` as an allowed <licence> value.
It does NOT enumerate `eupl` — which is what upstream nextcloud-app-template's
`main` branch currently ships, and is separately invalid.

A test first, and shown to fail. testGeneratedAppDeclaresTheRequestedLicence()
exports a real app and asserts info.xml declares EUPL-1.2 and does not declare
agpl. Against the pre-fix snapshot it failed with the generated app's info.xml
in the message; after the one-line change all 3 integration tests and all 746
unit tests pass. Nothing in the suite covered this before: the existing
unresolved-placeholder assertion matches /\{\{[a-zA-Z]+\}\}/, and a hardcoded
wrong value contains no placeholder to be left unresolved.

Why this is a one-line Edit and not the whole-tree refresh docs/releasing.md
prescribes. The refresh is unsafe as written, measured three ways:

  1. There is no upstream ref this tree fast-forwards from. The declared
     sourceCommit 7ee06aae is not an ancestor of upstream main OR development;
     `git branch -r --contains` places it only on two abandoned wsl-rescue/*
     branches.

  2. The snapshot carries OpenBuild-only fixes upstream never received — at
     least 10 commits have edited files inside lib/Resources/template/ since,
     among them c87d8c4 "fix(template): the generated app could not be built
     at all (#39)" and 12da26f "fix(export): make exported app a Tier-4
     manifest consumer (ADR-024)". `rsync --delete` reverts both.

  3. The dialects differ. OpenBuild resolves {{token}}; upstream's info.xml
     uses {APP_NAME}/{APP_SUMMARY}/{APP_DESCRIPTION}. PlaceholderResolver does
     not know that dialect and the unresolved-placeholder assertion does not
     match it, so a refreshed tree would ship literal {APP_NAME} into every
     generated app and no test would fail.

.snapshot-meta.json and docs/releasing.md now say so at the point of use, so
the next agent does not run the rsync on the strength of the doc alone.
docs/releasing.md also now records that the "CI drift check" it describes does
not exist — no workflow mentions drift or nextcloud-app-template — which is
why the snapshot sat 86 days stale shipping the wrong licence.

.path-manifest.txt no longer lists .snapshot-meta.json: the regeneration
command in docs/releasing.md excludes it, so the checked-in manifest
disagreed with its own generator.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
* ci: run the E2E (Playwright) job — it has never executed

`enable-playwright` defaults to false and code-quality.yml never set it, so
the shared workflow's `E2E Tests (Playwright)` job has reported `skipped` on
every run this repo has produced, while 50 spec files sat in tests/e2e/.
Unlike `enable-newman: false`, nothing recorded that as a decision.

Adds `enable-playwright: true` + `playwright-test-path: tests/e2e`, and the
config that path selects. The workflow runs its chosen config with NO
`--project`, so the root config would have run `docs-capture` (owned by the
Journeydoc Capture job) and `visual` (PNG baselines its own header says a CI
Linux runner cannot byte-match) alongside the regression suite.
tests/e2e/playwright.config.ts declares only `chromium`, and writes its
report/traces to the app root — the paths the workflow's upload steps look
at. The root config is untouched.

`playwright-seed-command` supplies the two pieces of instance setup
global-setup.ts can only do through a docker container (it refuses to guess
one, by design): disabling the wizard's 10/hour user rate limit, and seeding
the hello-world fixture via the test-only occ command.

* ci: run on Postgres — OpenRegister cannot run on SQLite

Measured on run 31018480923: the E2E seed died before a single spec ran with

  Failed to seed hello-world fixture: … SQLSTATE[HY000]: General error:
  1 no such table: information_schema.columns

OR's MagicTableHandler reads `information_schema.columns` to decide whether
an object table needs creating; Postgres and MySQL ship that view, SQLite
does not have it. Same family as documented Newman blocker #2 (`no such
function: REGEXP`, also OR's MagicMapper, also SQLite-only).

`pgsql` is the shared workflow's own default — the repos whose E2E is green
(opencatalogi, doriath) set no `database:` at all. Stated explicitly rather
than deleted so the reason survives.

* fix(seed): the hybrid example could never be seeded

Measured on run 31029961494, the first time the E2E job has ever run:

  Failed to seed hello-world fixture: A hybrid app's description is read-only
  — it mirrors the installed Nextcloud app it customizes.

HybridMetadataLockListener locks slug/name/description/productionVersion on a
hybrid app and fires on ObjectUpdatingEvent only — creation with the locked
identity is explicitly allowed. seedHybridExample() created the Application,
created the version, then UPDATED the Application to attach productionVersion:
an update touching two locked fields at once, rejected every time. So the
hybrid fixture has been unseedable since that listener shipped, and nothing
said so — globalSetup swallows a seed failure as a warning, and the E2E job
had never run in CI.

Mint the Application UUID up front, create the version against it, then write
the Application in ONE create carrying its full identity and the production
pointer. The lock is satisfied by construction, not bypassed. The forward
reference is safe: OR validates relation targets only for `$ref` properties
carrying `validateReference`, and ApplicationVersion.application has neither.

* ci(e2e): make the Playwright job produce a verdict, and fix the deep-link root cause

The first run of this suite in repo history (run 31030663352, job 92390852268)
was CANCELLED at the shared workflow's 45-minute cap after 45m16s. A
cancellation is not a verdict: no tally, and — because Playwright writes its
HTML report only after the last test — `actions/upload-artifact` found nothing
("No files were found with the provided path: .../playwright-report/"), so
there were no traces or screenshots either.

What it did measure before it died, read out of the job log:

    COLLECTED  249 tests in 50 files
    REACHED     83 unique tests (98 attempts, 15 of them retries)
    pass         4
    fail        68
    skip        11
    slowest PASSING test  3.5s   (the four passes: 0.4s, 0.4s, 2.9s, 3.5s)
    time in failures      29.6 min of 42.5

BUDGET — tightened only in the strict direction

* retries 1 -> 0. A retry can only convert red to green; it bought nothing and
  doubled the cost of every failure, which is what blew the budget.
* per-test timeout 60s -> 30s. Measured: the slowest test that PASSED took
  3.5s, so 30s is ~8.5x it, and still above the largest wait a spec asks for
  itself (`waitForSelector(…, 20_000)`) plus navigation, so a genuine failure
  still reports its own message. Six component-blocks failures had been sitting
  on the full 60s.
* NEW globalTimeout of 36 min, below the job's 45. Playwright now stops itself,
  prints the tally, and flushes the report so the artifact uploads.
* workers stays 1. Four independent classes of cross-talk make parallelism
  unsafe (shared `hello-world` slug with three writers, exact-count assertions,
  the brute-force throttle, and the `_limit=5000` backend bottleneck). It is
  documented in the config so it is not raised later to save wall clock.

ROOT CAUSE OF THE FAILURES — `htaccess.IgnoreFrontController`

openbuild builds its router as `createWebHistory(generateUrl('/apps/openbuild'))`,
and `generateUrl()` prefixes `/index.php` unless the served page reports
`modRewriteWorking`. A freshly `maintenance:install`ed instance does not set it,
so the router base is `/index.php/apps/openbuild` while every spec navigates to
the pretty `/apps/openbuild/...`. They disagree, vue-router matches nothing, and
the SPA mounts and renders perfectly — on the DASHBOARD. No 404, no console
error, no failing request.

The pass/fail pattern is the proof. The only UI tests that passed were
builder-host's first two, whose inner router is based at
`/apps/openbuild/builder/<slug>` — the exact URL the spec opens — so its DEFAULT
route is the page under assertion. builder-host's third test, the only one that
needs the `/messages/new` SUB-path to survive, failed with the rest. Everything
else that passed was a pure `request.get()` API test.

The CI router already serves pretty URLs (the workflow's own front-controller
check proves it), so setting the flag states something TRUE about the instance
rather than working around anything.

SEED — adopt tests/e2e/ci-seed.sh (ported from #111)

`playwright-seed-command` becomes `bash apps/openbuild/tests/e2e/ci-seed.sh`,
replacing the two-command inline string. Beyond the pretty-URL fix it: forces
the register import over the admin HTTP API (the `InitializeSettings` repair
step runs session-less, so OR's RBAC denies it and the step downgrades the throw
to a warning) and VERIFIES all 15 schema slugs; pre-marks the admin as a
returning user so nc-vue's first-visit walkthrough and support dialog stop
swallowing clicks in every fresh Playwright context — via the product's own
preference mechanism, and deliberately NOT for the rbac-* fixture users, whose
specs assert exactly that; proves the hello-world fixture over HTTP rather than
trusting the occ exit code; and GATES on the served bundle actually being
JavaScript, because a missing bundle returns HTTP 200 text/html, not 404.

HARNESS HONESTY — ported from #111

* tests/e2e/support/baseUrl.ts accepts `BASE_URL` (which is what the shared
  workflow exports) and no longer defaults to localhost:8080 off CI — that is
  the SHARED dev container, and this suite WRITES.
* tests/e2e/global-setup.ts fails LOUDLY on CI when admin or an rbac-* session
  cannot be minted. An absent storage state does not error; the spec just runs
  UNAUTHENTICATED, and rbac-403 / versionRouting then pass for exactly the wrong
  reason.
* tests/e2e/.gitignore — the root ignores are root-anchored and do not match
  these paths.

APP DEFECTS FIXED

* HybridMetadataLockListener no longer locks `productionVersion` (the canonical
  spec REQUIRES a hybrid app to point at its delta version, so the lock made
  every create/republish path fail at its last step) or `description` (never in
  the spec's lock list, and OR's PUT-semantic saveObject delivers an unmentioned
  field as null, which the lock read as a deliberate edit). Unit test updated.
* register.d/10-business-rules.json: dropped an empty `"condities": {}` — OR
  rejects both `{}` and `null` for object properties, so the key has to be
  omitted.

* fix(seed): the production-pointer write silently wiped hello-world's permissions

MEASURED VERDICT that this fixes (run 31040914410, job 92425229324) — the first
run of this suite that finished on its own:

    COLLECTED 249   REACHED 249   pass 154   fail 19   skip 76   23.6 min

(the previous run was CANCELLED at the 45-minute cap having reached 83 of 249
with 4 passes, and uploaded no report at all).

Of the 19 failures, 14 share ONE cause, and it is not a permissions bug in the
product — it is a partial write in the seeder.

OR's `saveObject()` update path is PUT-semantic:
`SaveObject::fillMissingSchemaPropertiesWithNull()` sets every schema property
absent from the payload to null. Step 3 of the hello-world seed exists only to
attach `productionVersion`, and it listed four fields. `permissions` was not one
of them — so the owner block step 1 had just written was nulled a moment later,
and the fixture came out owned by NOBODY (`allowAdminBypass` is false, so an
empty block denies everyone).

From each failing spec's side that is indistinguishable from the product
refusing a legitimate owner:

  * spec-coverage/openbuild-runtime REQ-OBR-005 x4 — `locator.fill` on
    `<textarea readonly … data-testid="openbuild-editor-textarea">`;
    `:readonly` is bound to `obAppRole === 'viewer' || 'none'`.
  * REQ-OBR-006b, REQ-OBR-008b — the owner-only Settings menu entry absent,
    and "an owner must get an editable manifest" failing on the same readonly.
  * copilot-panel — `403 {"error":"forbidden","message":"You do not have owner
    or editor access to application 'hello-world'."}`
  * automations x7 — the edit modal never closes because the save 403s; the
    failure ARIA snapshot shows `alert: "Could not save the automation."`, and
    the four later scenarios then cascade on the row that was never created.
    (Automation ops require WRITE_ROLES ['owners','editors'].)

The fix carries `permissions` forward in that write, with a comment naming the
PUT semantics so it is not trimmed again as "redundant".

PROOF IT CAN FAIL — new tests/Unit/Command/SeedHelloWorldFixtureTest.php

The field-preservation assertion the failure mode demands: it asserts the LAST
Application write still carries `permissions.owners`. A test that only checked
the field the write MEANT to change passes on the broken code. Mutation-proved
locally against PHP 8.4:

  * remove `permissions` from the pointer write   -> 1 failure
    ("Failed asserting that null is identical to Array [0 => 'user:admin']")
  * replace the minted UUID with a constant       -> 2 failures
    (v4 shape, and each run minting a distinct id)
  * restore HybridMetadataLockListener's old
    LOCKED_FIELDS                                 -> 3 failures in that
    listener's own suite

All restored; 751 tests green locally (8 errors are `ZipArchive` missing from
the throwaway PHP container, absent in CI).

ALSO

* tests/e2e/spec-coverage/dashboard-overview.spec.ts — `a[href$="/apps/openbuild/"]`
  matches TWO links, and `.first()` is Nextcloud's GLOBAL app-menu entry, which
  is labelled "OpenBuild", not the in-app "Dashboard" nav entry. Read straight
  off the failure's ARIA snapshot. Scoped to `#app-navigation-vue` — the id
  NcAppNavigation renders and the target of the page's own "Skip to app
  navigation" link, containing all four in-app entries. A narrower locator; the
  label assertion is unchanged. The other three entries had been passing only
  because no global menu link ends in `/applications`, `/templates` or
  `/features-roadmap`.
* SeedHelloWorldFixture: two comment lines re-indented — PHPCS reads `//   x` as
  an inline comment with 3 leading spaces and errors ("use block comment if you
  need indentation"). That was the whole of the red `PHP Quality (phpcs)` job.

* fix: hoist the seed permissions to a constant, and floor the bundle gate on size

MEASURED (run 31046063272, job 92443507627) — the previous commit's effect:

    COLLECTED 249   REACHED 249   pass 169   fail 4   skip 76   18.8 min

against 154/19/76 in 23.6 min before it. Carrying `permissions` through the
production-pointer write cleared 15 failures, and the run also got 4.8 minutes
faster because fourteen of them were no longer sitting on a timeout.

That commit's explanation lived inside `execute()`, which two gates objected to
and both were right:

  * PHPCS — `//   403 {...}` is an inline comment with 3 leading spaces
    ("use block comment if you need indentation").
  * PHPMD — ExcessiveMethodLength: `execute()` reached 125 lines against a
    threshold of 100.

Fixed by giving the block a better home rather than trimming the reasoning: a
`SEED_PERMISSIONS` class constant carrying the full docblock, referenced from
both Application writes. That also removes the duplicated literal, so the two
writes can no longer drift apart — which is the failure mode the comment is
about.

BUNDLE GATE — content type alone cannot catch a truncated bundle

`ci-seed.sh` asserted the served bundle's Content-Type was JavaScript. A
zero-byte or truncated bundle still serves `200 application/javascript`, so
that gate passes over precisely the failure it exists to catch — reported live
on a sibling repo where it did exactly that. Added a floor on the byte count
curl already reports (measured on run 31040914410: 13,552,436 bytes; floor set
at 100,000 so it fires on truncation without becoming a second size budget that
silently drifts).

REMAINING FAILURES — 4, all root-caused, none of them a budget artifact:

  * docudesk-document-templates ×3 — `/apps/docudesk/api/templates` 404s
    because docudesk is not installed on the runner. The third failure is
    self-documenting: the page renders "Docudesk is not installed or enabled on
    this instance" alongside the warning the spec was looking for. The fix is
    adding docudesk to `additional-apps` so these RUN; that input feeds every
    job in the workflow including six PHPUnit matrix cells, so it belongs in its
    own change, not here, and NOT behind a ninth skip.
  * page-editor-coverage REQ-PEC-006 — `selectOption('openbuild-hello-world-production')`
    on a `<select aria-invalid="true">` with "did not find some options". The
    per-version data register that name refers to does not exist: the seeded
    ApplicationVersion records `register: 'openbuild-hello-world'` as metadata
    only. A real fixture gap in what the seeder provisions, and which service
    ought to create that register is a product question, not a guess.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…kflow needs

The Hydra Gates job fails with a message that says outright it is not about this
repository:

  hydra-gates-ref <old> does not contain: scripts/lib/check_spec_anchors.py
  scripts/lib/check_form_labels.py scripts/lib/check_license_triangle.py

The reusable workflow floats on @main and calls those scripts BY PATH inside the
PINNED package, so a pin older than the scripts cannot run the gates that
implement them. A pinned ref is a silent expiry date on every upstream change,
and the failure reports on the pin while saying nothing about the code.

v1.5.0 is the first tag containing all of them, verified by reading each path at
that tag rather than assuming the newest tag has everything.

Swept across the fleet: 11 of 13 repos were pinned below v1.5.0 and every one of
them was failing this way.
rubenvdlinde and others added 28 commits August 19, 2026 21:26
…elopment/marked-18.0.9

build(deps): bump marked from 12.0.2 to 18.0.9
…elopment/cyclonedx/cyclonedx-npm-6.0.1

build(deps-dev): bump @cyclonedx/cyclonedx-npm from 4.2.1 to 6.0.1
…ment/nextcloud/ocp-34.0.3

build(deps-dev): bump nextcloud/ocp from 34.0.2 to 34.0.3
…ment/twig/twig-3.28.0

build(deps-dev): bump twig/twig from 3.27.1 to 3.28.0
…ment/phpmetrics/phpmetrics-2.11.0

build(deps-dev): bump phpmetrics/phpmetrics from 2.9.1 to 2.11.0
…tservice-lazily

fix: event listeners cannot constructor-inject a published OpenRegister interface
…st (#271)

* fix(tests): stub the OpenRegister FlowService for AppChannelApplierTest

The unit-test bootstrap resolves OpenRegister types via stubs so
PHPUnit's MockBuilder can build them outside a real container.
FlowChannelProvisioner depends on FlowService (ADR-065's sanctioned
flow entry point) but no stub existed for it, so all 18
AppChannelApplierTest cases failed on a clean development checkout
with "Class or interface OCA\OpenRegister\Service\Flow\FlowService
does not exist" rather than exercising the intended assertions.

* fix(phpcs): inline @var docblocks must use /* */, not /** */

CI's phpcs gate runs against all of lib/ on every PR, so this
pre-existing violation (present on development itself, unrelated to
the FlowService stub this branch adds) was blocking this PR's own
check. Same convention already followed elsewhere in the codebase
(e.g. RuleActionDispatcher.php).

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* fix(adr-083): guard the three unguarded OpenRegister reaches

gate-66 (openregister-dependency-shape) reported 3 violations, all the same
shape: `objectService()` looked the service up in the container with nothing
establishing that OpenRegister is installed first.

BEHAVIOUR IS UNCHANGED. Every caller already wraps the reach in a catch that
logs and returns null, so an instance without OpenRegister degrades exactly as
it does today. What changes is that the dependency is declared where a reader —
and the gate — can see it, instead of being implied by a catch several methods
away, outside the window the gate can correlate.

class_exists() rather than a settings service, because none of these listeners
injects one and adding a constructor dependency purely to ask a yes/no question
is the wrong trade. ADR-083 lists class_exists as an accepted way to establish
availability precisely because it answers the same question the DI container
would otherwise have answered fatally.

Measured with a control on the same tree and checker:
  before: 3 violations
  after:  0 violations, 138 files checked
php -l clean on all three.

⚠️ gate-61 (6 listeners doing synchronous work with no deferral or
justification) is NOT addressed here. Each needs its own judgement about
whether the work must be synchronous — that is a separate change, not a
docblock sweep.

* style(phpcs): replace the inline @var docblocks with assert() narrowing

phpcs forbids a `/** @var */` docblock inside a method body ("Inline doc block
comments are not allowed"), while psalm needs that narrowing or the declared
`: ObjectServiceInterface` return becomes a MixedReturnStatement — the two
tools disagreed on the same three lines.

`assert($service instanceof ObjectServiceInterface)` satisfies both: psalm
narrows on it, phpcs sees a statement rather than a comment, and production
pays nothing because zend.assertions is off.

⚠️ These three errors are PRE-EXISTING on development, not introduced here —
verified by running phpcs against origin/development, which reports the same 3.
The old permissive gate mapped phpcs exit 1 to success, so they shipped
unnoticed; .github#483 now makes them fatal, and this PR touches those exact
files.

⚠️ They also mean my pre-#483 fleet sweep UNDER-REPORTED. I claimed "0 phpcs
errors across 21 repos"; openbuild had 3. The CI log did say so — three
per-file "FOUND 1 ERROR" lines — but my summing collapsed them with `sort -u`
before counting, so three files reporting one error each read as one distinct
line. Count occurrences, not distinct lines.

Verified after the fix: phpcs 0 errors in lib/, gate-66 0 violations, php -l
clean on all three, psalm exit 0 with no Listener findings.

* fix(coverage): mark the ADR-083 class_exists() guards as uncoverable

The unit-test bootstrap's OpenRegister stubs (tests/stubs/openregister-stubs.php)
make class_exists('OCA\OpenRegister\Service\ObjectService') true
unconditionally in this suite, so the "OpenRegister genuinely absent"
branch this change adds can only ever fire on a real instance —
there is no way to exercise it from a unit test without faking
class_exists() itself. @codeCoverageIgnoreStart/End excludes it from
the coverage-ratchet's statement count instead of leaving it as an
uncovered addition.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
github-store.spec.ts deliberately covers only the read side (search/
browse) against the shared dev instance, by design — a write path
must never touch shared data. POST /apps/openbuild/api/shop/github/
install (ShopController::githubInstall()), which installs a real
app-repo-format-v2 repo end to end (register + connectors/automations/
flows + skills + agents channels), had only ever been exercised by
hand, once, in the throwaway session that produced #264 — never by a
repeatable, automated check. app-channel-application's own spec
already documents this exact scenario ("Installing from the shop
applies its channels") but carried no @e2e proof until now.

Runs against the disposable instance only (PLAYWRIGHT_BASE_URL), gated
on the same GitHub-credential capability probe github-store.spec.ts
uses — skips cleanly with a stated reason when no credential is
granted, rather than failing or fabricating a pass.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
)

* fix(export): find an application's agents in hermiq's register too

FlowAndAgentExportBundler::bundleAgents() queries only register: openbuild for
an application's agents, so an agent living in hermiq's own register (real for
hydra-console, whose agents include hermiq-seeded and hand-created ones) was
silently invisible to every export — no exception, no reported skip, just an
export with no agents.

Adds a fallback: when openbuild's own agent schema finds nothing and hermiq is
installed, a second query runs against hermiq's register for the same
applicationSlug, guarded via IAppManager the same way SkillChannelDelegate
already treats hermiq as optional. hermiq's richer agent shape travels into the
export unmodified, never coerced into openbuild's own narrower schema.

Live-verified on a fresh isolated instance against real backfilled
hermiq-register agents: the old code silently found zero agents for
hydra-console despite three real matches; the new code finds all three and
correctly excludes a fourth carrying a different applicationSlug.

* fix(tests): PHPCS compliance and coverage for the hermiq-register agent fallback

- Add testTheFallbackDegradesToEmptyWhenHermiqsOwnLookupThrows to close
  a coverage-ratchet gap on findHermiqRegisterAgentsOrEmpty()'s catch
  branch.
- Convert positional arguments to named parameters throughout the new
  fallback test coverage and the touched ExportServiceTest constructor
  call, per this repo's PHPCS convention.
- Fix a pre-existing PHPCS error in DocumentGenerationListener.php
  (double-asterisk inline @var docblock, disallowed) encountered while
  bringing this PR's touched files to a clean phpcs run.

* fix(tests): ExporterEndToEndTest's FlowAndAgentExportBundler double needs appManager

The exporter-e2e job constructs FlowAndAgentExportBundler directly with
the pre-this-PR 3-argument signature (flowMapper, objectService,
logger). This PR's new hermiq-register fallback needs an IAppManager
to check whether hermiq is installed, so the constructor gained a
required appManager parameter — this call site was the one other place
in the repo building the class directly and needed updating alongside
ExportServiceTest's already-fixed one.

* fix(phpcs): inline @var docblocks must use /* */, not /** */

Same pre-existing lib/-wide violation as DocumentGenerationListener.php
in the previous commit — CI's phpcs gate scans all of lib/, and these
two files hit the same rule.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…rite (#273)

DocumentGenerationListener and AutomationApprovalTriggerListener both
fire on ObjectCreatedEvent/ObjectUpdatedEvent/ObjectDeletedEvent and
scan the whole openbuild-register automation schema with an unbounded
findAll() to find matching automations — the exact OpenRegisterFlowResolver
failure mode gate-61 (ADR-078) measures against, just scoped to a
smaller register.

A real install's configured automations number in the tens; the bound
exists to cap worst case, not because that count is expected. Full
async deferral was considered and rejected here: these listeners
already query synchronously by design (the trigger needs to resolve
before dispatching the matching automation's action), and a bound is
the honest fix for "unbounded," not a workaround for it.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…debar race

`development` E2E was red on one test of 192:

    ✘ app-icon-management — removing an icon (REQ-OBICON-004)
      Error: ApplicationIconTab must mount IconUploadSection
      30 × locator resolved to <div class="ob-icon-section">…</div>
         - unexpected value "hidden"

Thirty resolutions is the whole diagnosis. The locator never reported
"not found" — the component HAD mounted. It was present and hidden, and
the wait was hopeless from its first poll.

openIconsTab already wraps open-sidebar → click-tab → assert-panel in
`.toPass()`, because the file documents that the detail page re-closes the
sidebar while it is still hydrating. The section assertion sat AFTER that
retry. So when the sidebar re-closes in the window between the panel being
seen and the section being checked, the section is in the DOM inside a
closed sidebar and nothing can reopen it — the assertion can only wait out
its budget, which is exactly what the trace shows.

Moving it inside the same block does not soften the check: the section
must still become visible. It just lets the idempotent open-and-click that
already guards the tab recover the sidebar when it closes late.

The twin helper in tests/e2e/iconUpload.spec.ts gets the same treatment.
It had no retry at all — an instant `isVisible()` probe (which does not
wait) followed by a click — and passed only because
`dismissFirstVisitOverlays()` burns a few seconds ahead of it. The
app-icon-management copy already called that out as "luck, not a guard";
this makes it a guard. `.app-sidebar__toggle` is a TOGGLE, so the old
shape could also click the sidebar shut.

Not reproduced locally: openbuild's e2e is built for a disposable
container and its own support/baseUrl.ts warns that pointing it at the
shared :8080 writes fixtures onto other people's instance. The change is
reasoned from the CI trace and mirrors the remedy this file already
applies one level up; CI is the verification.

Closes #268
…-the-retry

fix(e2e): assert the icon section inside the retry that guards the sidebar race
…oken

v1.8.0 shipped an ObjectServiceInterface WITHOUT patchObject() and with
updateObject() still summarised as "Apply a partial update" — the wording
that sent a consumer down the erasing path. The correction landed on main
two days after the tag; every app has been pinned to the broken copy since.

It is not confined to this repo: hydra-gates claims OCA\OpenRegister\Contract\
in its composer autoload, a LONGER psr-4 prefix than openregister's own
OCA\OpenRegister\ -> lib/, so the gate package wins. Nine repos vendor it, so
under OC_App::loadApps() whichever app registers first defines the contract
for the whole instance. Measured on a running instance, softwarecatalog's
vendor directory was supplying openregister's interface, and updating
openregister ALONE did not change the winner — which is why this lands
across the fleet rather than in one repo.
chore(deps): take hydra-gates v1.8.1 — the contract v1.8.0 shipped broken
…-unstable.20260819215516

# Conflicts:
#	appinfo/info.xml
…60819215516

chore(release): 0.6.1-unstable.20260819215516
`};//end match` closes a match EXPRESSION, not a declaration, so
Squiz.Commenting.ClosingDeclarationComment does not claim it and the ordinary
inline-comment rules apply: space after //, initial capital, full stop.

php_codesniffer 3.13.6 did not flag this; 4.0.4 does, and it is the only thing
standing between this repo and the phpcs 4 bump. Measured on decidesk: with
this one line fixed, 4.0.4 goes from exit 3 (2 errors) to exit 0.

The 1,833 `}//end method()` comments elsewhere are the recognised closing
form and are untouched.
style(phpcs): terminate the match-expression end comment (unblocks the phpcs 4 bump)
The stylelint config packages declare stylelint >=16 as a peer, so none can
move alone — each single-package dependabot PR left the tree unresolvable and
stylelint aborted before linting a file.

Bumped together: stylelint ^17.14.1, @nextcloud/stylelint-config ^3.2.2,
stylelint-config-recommended-scss ^17.0.1, stylelint-config-recommended-vue
^1.6.1 (the version @nextcloud/stylelint-config@3.2.2 peer-requires).

package-lock.json is REGENERATED rather than patched. The old lock pinned
stylelint-scss 5.3.2, whose peer is 'stylelint ^14.5.1 || ^15.0.0', and npm
will not move a transitive pin in place — every incremental install failed
ERESOLVE against it. The regeneration moves 14 packages across a major, and
all 14 are inside stylelint's own subtree (stylelint-scss, cosmiconfig,
css-tree, meow, globby, html-tags, write-file-atomic …), which is what a
15 -> 17 upgrade of this tool is.

Also fixes the one deprecated CSS declaration stylelint 17 promotes to an
error: word-break: break-word -> overflow-wrap: break-word.

npm run stylelint now exits 0.
…bump

build(deps-dev): bump stylelint to 17 as one coupled upgrade
…elopment/nextcloud/axios-2.6.0

build(deps): bump @nextcloud/axios from 2.5.2 to 2.6.0
Bumps [squizlabs/php_codesniffer](https://github.com/PHPCSStandards/PHP_CodeSniffer) from 3.13.6 to 4.0.4.
- [Release notes](https://github.com/PHPCSStandards/PHP_CodeSniffer/releases)
- [Changelog](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/4.x/CHANGELOG-3.x.md)
- [Commits](PHPCSStandards/PHP_CodeSniffer@3.13.6...4.0.4)

---
updated-dependencies:
- dependency-name: squizlabs/php_codesniffer
  dependency-version: 4.0.4
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Two user-visible defects are fixed by this bump.

TWO AI-COMPANION HEXES ON EVERY PAGE. The companion singleton landed in
2.7.0. Below that the host app's own companion never stands down, so any
page of this app rendered a second hex 8px from hermiq's — measured on a
running instance: openconnector (2.7.1) showed ONE, openbuild (2.6.3)
showed TWO, both visible at 52x60, from two separate mounts.

THE DETAIL PAGE RECLOSED ITS SIDEBAR WHILE HYDRATING. CnDetailPage set
sidebarSeeded and never read it, so 'open' was re-applied on every sync
and each reactive change during hydration reset it to the prop default.
Fixed in nextcloud-vue#711; that is the cause behind openbuild#268 and,
on the evidence, #188.

Lockfile only — the existing caret already allowed this. Three-line diff
(version, resolved, integrity).
…ment/squizlabs/php_codesniffer-4.0.4

build(deps-dev): bump squizlabs/php_codesniffer from 3.13.6 to 4.0.4
chore(deps): take @conduction/nextcloud-vue 2.8.2 (was 2.8.0)
Brings beta up to date with development (737 commits) for tonight's
fleet-wide beta checkup.

Conflicts resolved, all in development's favour:

  .forgejo/workflows/release-{beta,stable}.yml — DELETED on development as
  part of the Codeberg/Forgejo CI retirement; modified on beta by earlier
  dev-merge reverts. Codeberg is retired, so the deletions stand.

  src/builder.js, src/manifest.json — development is substantially ahead,
  not merely different: builder.js is 456 lines against beta's 251, and the
  manifest has migrated to the MDI icon vocabulary (ViewDashboardOutline)
  per ADR-077 while beta still carries the old icon-category-* classes.
  Beta's only unique src commit is a chore (version bump, layers icon,
  manifest, docs build) that development has since superseded.

  .gitignore, appinfo/info.xml — files development actively maintains.

Beta-only files that did not conflict are preserved.
@rubenvdlinde
rubenvdlinde merged commit 6350ea4 into beta Aug 20, 2026
39 of 40 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 9a16404

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
format
composer ✅ 106/106
npm ✅ 624/624
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-20 21:21 UTC

Download the full PDF report from the workflow artifacts.

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.

2 participants