Skip to content

feat(parafering): an approval route is a flow, not a schema #2485

feat(parafering): an approval route is a flow, not a schema

feat(parafering): an approval route is a flow, not a schema #2485

Workflow file for this run

name: Code Quality
on:
push:
# DEFAULT BRANCHES ONLY. `pull_request` below carries every other branch.
#
# This was an allow-list of branch prefixes, and that was a gate with a
# SILENT hole: a branch matching nothing got no CI at all, and its last
# visible status was whatever it inherited — indistinguishable, on every
# dashboard, from a branch that passed. Two live examples, both found
# 2026-08-14: `perf/**` was uncovered in openconnector, where a merge
# carrying unresolved conflict markers and 84 failing tests was pushed and
# nothing ran; and `feat/**` was uncovered in openregister, because the
# list said `feature/**`.
#
# The comment that stood here said adding prefixes was not the durable fix,
# and that the durable fix was to let the pull_request trigger gate it.
# THIS IS THAT CHANGE.
#
# What forced it now: a push to a branch with an open PR ran the SAME 34
# jobs TWICE on the same commit. `concurrency` cannot dedupe them — the
# group is suffixed by event name deliberately (.github#540: a
# default-branch push carries jobs a PR run does not, and a dispatch must
# not be cancellable by a standing release PR), so the two events sit in
# different lanes BY DESIGN and both run to completion. Measured fleet-wide
# 2026-08-25..27, 659 of 2,106 Code Quality runs were that duplicate — 31%
# of the fleet's most expensive workflow, re-deciding a commit another run
# was already deciding. The account ceiling is 60 concurrent jobs (Team
# plan); the fleet was measured at 53 running with 1,528 jobs queued behind
# them, the oldest run 7 hours old and not yet started.
#
# NO BRANCH LOSES ITS FLOOR. merge-hygiene.yml runs on `'**'` — every
# branch anyone pushes, no prefix list to forget — and it is the check
# `development` actually requires. That is the smoke alarm; this workflow
# is the fire brigade and belongs on the PR. Of 668 feature-branch push
# runs in that window, only NINE were on a branch with no PR run beside
# them.
#
# The default branches STAY: their push runs are not duplicates, they are
# the only carrier of Coverage Baseline Check, SBOM and Features Extract,
# none of which run on a pull_request event.
branches:
- main
- development
pull_request:
branches: [main, master, development]
workflow_dispatch:
# Deduplicating a `push` run against the `pull_request` run for the SAME head
# ref is the point of this block, and for a feature branch it is exactly right:
# two runs of identical jobs, one of them wasted.
#
# It is wrong for `main` and `development`, because the push run there is NOT a
# duplicate — it is the only carrier of the push-only jobs: "Coverage Baseline
# Check" (`github.event_name == 'push'`), "SBOM" and "Features Extract". A push
# to `development` and any open PR whose `head_ref` IS `development` both
# render the group `quality-development`, and `cancel-in-progress` then kills
# whichever started first — always the push run, by a few seconds.
#
# Note this repo's own wrinkle: the standing "Release: merge development into
# beta" (#18) targets `beta`, and `beta` is NOT in this workflow's
# `pull_request.branches` list, so that PR alone does not collide here. But
# #669 ("fix(security): wave-3 critical fixes") is head `development` -> base
# `main`, and `main` IS in the list — so the collision is live regardless, and
# will recur for any future development->main PR.
#
# Measured on this repo: push run 30896826480 cancelled 45s in. That duration
# is the discriminator: the shared workflow's `timeout-minutes: 45`
# cancellation lands at 45m16s–45m28s, so this is a concurrency kill.
#
# On the surviving PR run "Coverage Baseline Check" reports `skipped`, which is
# CORRECT for a pull_request event and renders exactly like a pass. So the gate
# appears on both runs and executes on neither — a dead gate of the
# permanently-pending shape.
#
# Suffixing only the default-branch push keeps feature-branch dedup untouched
# (`quality-feature/x` for both events, exactly as before) and gives the two
# default branches' push runs a lane of their own.
#
# Proven in openconnector#1158: its first-ever completed `development` push run
# (31048998594) executed Coverage Baseline Check, SBOM and Features Extract.
concurrency:
# SUFFIXED BY EVENT NAME, not just by `-push`.
#
# The previous expression gave a push on `development` its own lane
# (`-push`) but left EVERYTHING ELSE sharing `quality-development` — and
# that is not a quiet lane: `Sync to Beta` keeps a PR open whose head_ref
# IS `development`, so its run computes the same group and is re-triggered
# on every merge.
#
# A `workflow_dispatch` therefore shared a group with that PR and was
# cancelled by it. Measured on shillinq 2026-08-21: dispatch 32487948678
# cancelled by pull_request run 32490160836 (head_branch `development`).
# A run someone deliberately asked for could essentially never complete.
#
# That reaches past ad-hoc verification: the fleet gate-drift sweep
# (.github#523) dispatches per app with `--ref development`, because
# `schedule:` cannot choose a branch. Under the old group those runs are
# cancelled and report neither pass nor fail — and a routine that produces
# no verdict is indistinguishable from one that never ran.
#
# This is hermiq's form, already live there. Pull requests keep the bare
# group (so a PR still supersedes its own earlier run); push, dispatch and
# schedule each get their own lane.
#
# THE BRANCH RESTRICTION IS GONE, because it contradicted the sentence above.
#
# The suffix used to apply only when `ref_name` was `main` or `development`,
# so on every OTHER branch push and pull_request computed the SAME group —
# and `cancel-in-progress` made them kill each other. That became reachable
# when the push allow-list widened on 2026-08-14 to include `feat/**`,
# `fix/**`, `perf/**`, `refactor/**` and `chore/**`: those branches now get
# both a push run and a pull_request run for one commit.
#
# `quality / Quality Report` is a `needs:`-gated aggregator and reports
# FAILURE when its dependencies are CANCELLED, so the collision shows up as a
# red gate on a PR that was never actually evaluated — and re-running collides
# the same way. Measured on openregister#2821: a push run left queued and a
# pull_request run cancelled, 18 seconds apart, on one commit.
#
# A branch name is not a unique lane when two event types can each produce a
# run for it, so the event is now always part of the key.
group: quality-${{ github.head_ref || github.ref_name }}${{ github.event_name != 'pull_request' && format('-{0}', github.event_name) || '' }}
# PUSH RUNS ARE NOT CANCELLED — and this has to be said HERE, not only in the
# shared workflow. .github#597 set `cancel-in-progress` on quality.yml itself,
# but a caller's own concurrency cancels the whole run before the called
# workflow's setting can apply, so that fix reached only the apps that declare
# no concurrency of their own. Measured 2026-08-28 over push runs on
# `development` since #597: 0 of 11 cancelled where the caller was silent, 7 of
# 13 (54%) cancelled where the caller still said `true`.
#
# An integration branch needs a verdict per commit: the run being cancelled is
# the only thing that would have said whether what just landed is sound, and
# its replacement is cancelled too. `pull_request` keeps cancelling, where
# superseding really is correct.
cancel-in-progress: ${{ github.event_name != 'push' }}
# Permission CEILING for the called quality pipeline. GitHub statically
# validates the called workflow's declared job permissions against this
# grant — even for jobs that are disabled — so it must cover the maximum
# any nested job declares: journeydoc-capture (contents+actions write),
# update-baseline / features-extract (contents write), and the Quality
# Report PR comment (issues / pull-requests write).
permissions:
contents: write
actions: write
issues: write
pull-requests: write
jobs:
quality:
uses: ConductionNL/.github/.github/workflows/quality.yml@main
with:
app-name: dossiq
php-version: "8.3"
php-test-versions: '["8.3", "8.4"]'
# stable31 is REMOVED, not "dropped for coverage".
#
# The original reason recorded here — "openregister declares
# min-version=32" — is STALE: measured 2026-08-08, openregister@development
# declares min-version="28" (openregister#2380 reverted it). Do not rely on
# it. What made the stable31 leg worthless still happened, though:
# `occ app:enable openregister` failed with only a ::warning::, so the run
# continued WITHOUT its data layer and every /apps/openregister/... call
# returned Nextcloud's HTML 404 page — a red that said nothing.
#
# The standing reason is dossiq's own floor: appinfo/info.xml declares
# <nextcloud min-version="32"/>, so a leg below 32 would test a
# configuration this app does not claim to support.
# tests/Unit/AppInfo/NextcloudFloorMatrixTest.php holds the two in sync.
# newman, playwright and journeydoc-capture all pin
# `fromJSON(inputs.nextcloud-test-refs)[0]`, so the FIRST entry has to be a
# version openregister can load.
#
# THE LIST IS THE WHOLE DECLARED RANGE. An earlier revision of this comment
# said "stable33 is deliberately NOT added: this removes an impossible leg,
# it does not widen the matrix" — but the same change also dropped stable32,
# which was NOT impossible, it was the declared floor. appinfo/info.xml
# declares <nextcloud min-version="32" max-version="34"/>, so 32, 33 and 34
# each get a leg. tests/Unit/AppInfo/NextcloudFloorMatrixTest.php is what
# holds this list and that floor in sync.
nextcloud-test-refs: '["stable34", "stable32", "stable33"]'
enable-psalm: true
enable-phpstan: true
enable-phpmetrics: false
enable-frontend: true
enable-eslint: true
enable-phpunit: true
enable-coverage-guard: true
# Newman disabled: ZGW compliance tests have 95%+ failure rate because
# the ZGW API implementation is still in progress. Re-enable once the
# API passes at least the core CRUD assertions.
enable-newman: false
newman-collection-path: "data"
newman-environment-path: "tests/zgw/zgw-environment.json"
newman-seed-command: "bash apps/dossiq/tests/zgw/seed-consumers.sh"
# `ref` moved off `feature/php-linting`. That is a short-lived quality
# branch, not a line anyone develops against: pinning the FOUNDATION app
# to it made every CI instance behave unlike any environment dossiq is
# actually built or run in, and it silently rots the moment the branch is
# merged or deleted (the checkout step does `git clone --depth 1 --branch
# "$ref"`, which fails outright). dossiq's own appinfo/routes.php depends
# on `\OCA\OpenRegister\AppHost\Routes::standard()` and the E2E job's seed
# needs the `settings#load` route it ships — `development` is where that
# plumbing lands first.
additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"}]'
# SBOM was disabled here until @conduction/nextcloud-vue's dependency
# declarations were cleaned up — its `npm ls` tree fails ELSPROBLEMS
# (bootstrap-vue is a non-optional peer, apexcharts/pinia/vue version
# ranges don't match what apps install, @types/react comes from a
# transitive rehype-react), and @cyclonedx/cyclonedx-npm propagated that
# as a hard failure because it always runs `npm ls` under the hood.
# Tracked in #434.
#
# That blocker is STALE, and the old comment's last line ("No other
# Conduction app enables SBOM today") is now false — which is exactly why
# it was worth re-measuring rather than trusting. The shared workflow's
# npm SBOM step now runs:
#
# npx @cyclonedx/cyclonedx-npm --package-lock-only --ignore-npm-errors …
#
# `--ignore-npm-errors` is the flag that neutralises ELSPROBLEMS, and it
# was not there when this was switched off.
#
# Measured positive control rather than assumed: ConductionNL/docudesk
# depends on the SAME `@conduction/nextcloud-vue` version this repo pins
# (2.2.0-vue3.3), and its SBOM job on `development` ran the npm leg to
# completion and merged the PHP + npm SBOMs — run 31016019235, job
# 92340789907. openregister, opencatalogi and doriath are green on SBOM
# too. The dependency this comment blamed does not stop the job in four
# sibling repos, so it does not stop it here.
#
# dossiq already ships the other prerequisite: `composer.json` requires
# cyclonedx/cyclonedx-php-composer ^6.2 and allows its plugin, so the
# `composer CycloneDX:make-sbom` step has what it needs.
#
# NOTE: SBOM only runs on a branch push (its `if:` tests github.ref
# against refs/heads/{main,beta,development}); on a pull_request the ref
# is refs/pull/N/merge, so this job is invisible on the PR that enables
# it and first reports on the merge to `development`.
enable-sbom: true
# ── E2E browser tests ────────────────────────────────────────────────
# Previously LEFT OFF. The comment that lived here recorded the blocker,
# so it is kept (not deleted) next to what changed:
#
# "Playwright disabled until the upstream @conduction/nextcloud-vue
# CnObjectDataWidget bundling bug is fixed: the published bundle has a
# hard-coded `require('../../store/index.js')` inside a soft try/catch
# that webpack can't resolve in consumer apps, so the dossiq bundle
# fails to build and every E2E spec then 404s. Tracked in
# ConductionNL/nextcloud-vue#242 — re-enable once a beta past that fix
# is pinned in package.json."
#
# That precondition is met: package.json now pins
# @conduction/nextcloud-vue 2.1.0-vue3.16 (the Vue 3 line, past #242) and
# `npm run build` emits js/dossiq-main.js. It is no longer taken on
# trust either — `ci-seed.sh` ends by FETCHING the bundle over HTTP and
# failing the step unless the response is real JavaScript of non-trivial
# size. A missing bundle returns HTTP 200 `text/html` (the Nextcloud error
# page through index.php), never a 404, so a status-code check alone would
# read the exact failure this input was disabled for as a success.
enable-playwright: true
# Double duty in the shared workflow: it is both the directory the
# "Validate Playwright tests exist" step counts *.spec.ts in, AND the
# FIRST place the run step looks for a config
# (`${playwright-test-path}/playwright.config.ts`), falling back to the
# repo root only if that file is absent. We ship
# tests/e2e/playwright.config.ts precisely so that lookup hits it: the run
# step passes no `--project`, so the ROOT config would also run
# `docs-capture` (re-shooting every documentation screenshot on every PR)
# and `visual` (whose own README records that a CI Linux runner cannot
# byte-match a dev-container PNG baseline).
playwright-test-path: tests/e2e
# Left OFF deliberately. Turning it on adds a hard threshold gate to a job
# that has never run here; enable it in a follow-up once this job has a
# measured baseline. The threshold below is inert while this is false.
enable-playwright-coverage: false
playwright-coverage-threshold: 75
# WAS `php occ maintenance:repair`. That is the IRepairStep path, and it
# CANNOT provision dossiq's register: a repair step runs with no user
# session, so OpenRegister RBAC denies the import ("User 'Anonymous' does
# not have permission to 'create' objects in schema '…'"),
# Repair\InitializeSettings::run() catches the Throwable and downgrades it
# to a warning, and occ still exits 0. The register is absent, the app
# looks fine, and every fixture call then 404s.
#
# ci-seed.sh instead imports EXPLICITLY over the admin HTTP API
# (POST /apps/dossiq/api/settings/load → loadConfiguration(force: true),
# which also deep-merges the 20 lib/Settings/register.d/*.json fragments)
# and then VERIFIES the register + schema slugs, so a bad provision is one
# loud step failure instead of a hundred misleading spec failures.
#
# cwd for this step is the Nextcloud server root.
playwright-seed-command: 'bash apps/dossiq/tests/e2e/ci-seed.sh'
# Integration Tests (Newman) stays OFF here, deliberately. The
# `enable-newman: false` further up is not a default nobody chose: it
# records that the ZGW compliance collections fail at 95%+ because the ZGW
# API implementation is still in progress. Four collections are committed
# under `data/` and would all meet that same known cause — a guaranteed
# red that teaches nothing the comment does not already say. Flipped back
# on in the commit that gets the core CRUD assertions passing.
# ── Frontend Check legs ──────────────────────────────────────────────
# `frontend-checks` defaults to `[]`, and an empty list means the shared
# workflow emits NO "Frontend Check" job at all — so these three
# validators ran nowhere while the run still looked complete.
# Measured on this tree before enabling: `test:l10n` PASSES;
# `check:manifest` FAILS on three counts — `pages[4]` and `pages[5]`
# (`type=custom requires component field`) and `pages[52].type: "roadmap"
# not in v1.2 enum`. `check:vue3-compile` could not be measured locally
# (it needs `@vue/compiler-sfc`, which only exists after the leg's own
# `npm ci`), so CI is the first place it gets a real verdict.
# `test` / `test:unit` are NOT listed: "Frontend Tests (unit)" runs them.
#
# `format` (prettier --check) is listed because the shared workflow has NO
# prettier job of its own — `quality.yml` runs eslint and stylelint and
# mentions prettier ZERO times. This repo already carries
# `@nextcloud/prettier-config` and a `format` script, so without this leg
# `npm run format` never runs outside a developer's editor and the tree
# drifts straight back out of format between merges — the same inert-
# formatter failure mode that made the old `.prettierrc` worth deleting.
# Centralising the config never stopped drift; the gate does.
# Measured on this tree before enabling: PASSES, 410 of 456 tracked
# frontend files in scope (l10n/ and docs/ excluded via .prettierignore /
# .gitignore, which prettier 3 also reads).
# `check:schema-l10n` is a RATCHET, not a gate. Every string inside a form
# comes from the schema and is a key in THIS app's catalogue; an absent key
# renders the English source inside an otherwise translated form, silently.
# The fleet had 30,459 such strings, so this records the current count and
# fails only when it GROWS — burning it down stays an ordinary PR.
frontend-checks: '["check:manifest", "check:vue3-compile", "test:l10n", "format", "check:schema-l10n", "check:l10n-js"]'
# ── Hydra mechanical gates ───────────────────────────────────────────
# `enable-hydra-gates` defaults to FALSE, so this tier had never executed
# here — the job reported `skipped`, which the Quality Report renders
# identically to a pass.
enable-hydra-gates: true
# No `hydra-gates-ref` here on purpose. The shared workflow defaults it
# to @main, and this workflow is itself consumed at @main, so the two
# sides move together and a gate fix reaches this repo without a commit
# in this repo. A pin is a silent expiry date: 22 repos sat on v1.0.1 and
# 16 gates were dead fleet-wide while every one reported PASS (.github#159),
# and a default flipped at @main later reached those old runners and made
# them red on gates they had no subject matter for (.github#173).
# To hold this repo still for a specific reason, set the input explicitly
# and say why — it is still honoured. To roll back for everyone, revert on
# ConductionNL/.github main.
#
# THIRD CAUSE, and the one that is failing this repo RIGHT NOW
# (.github#177): quality.yml@main began executing three gate helpers BY
# NAME — check_spec_anchors.py, check_form_labels.py and
# check_license_triangle.py — which exist in NO tag before v1.5.0.
# Verified by DIRECTORY LISTING of each tag, not by per-file lookups:
# those answered "present" uniformly across v1.0.0..v1.5.0, and the
# uniformity across independent inputs was the tell that the instrument
# was wrong. So the Hydra Gates job here fails at "Verify the pinned gates
# package satisfies this workflow", before a single gate runs, with the
# workflow's own words: "This is NOT a code-quality finding about your
# repository." Removing the pin is the repair.
#
# Unpinning also picks up v1.5.1's push scoping (.github#179): on a push to
# `development`, `origin/development` IS `HEAD`, so the diff was empty by
# construction — <= v1.4.0 passed over it (permanently green) and v1.5.0
# refused with exit 99 (permanently red). The scope is now
# `github.event.before...HEAD`, what the push actually changed.
#
# `enable-axe` is deliberately still NOT set — a vanilla Nextcloud 34
# reports serious/critical violations on core's OWN routes that DOM
# scoping does not remove. Enabling axe is a separate decision.