chore(sync): carry beta back into development #1449
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: | |
| # `synchronize` — a push to an open PR — was missing. Without it the quality | |
| # suite runs ONCE, when the PR is opened, and every commit after that is | |
| # merged unchecked while the PR still shows the first run's green tick. A | |
| # green check here described code that no longer existed. | |
| # | |
| # The branch list on `push:` does not cover the gap: it names `feature/**` | |
| # while the convention in practice is also `feat/**`, `fix/**` and `ci/**`. | |
| # | |
| # Cost is bounded by the `concurrency` block below — a new push cancels the | |
| # in-flight run for the same head ref rather than queueing beside it. | |
| types: [opened, reopened, synchronize] | |
| branches: [main, master, development, beta] | |
| 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". And | |
| # those two branches always have an open PR whose `head_ref` IS the branch | |
| # name: the standing "Release: merge development into beta" (#4 here). | |
| # `github.head_ref` on that PR run and `github.ref_name` on the push run both | |
| # render `development`, so both landed in the identical group | |
| # `quality-development`, and `cancel-in-progress` killed whichever started | |
| # first — always the push run, by a few seconds. | |
| # | |
| # Measured on this repo: 11 of the last 20 `development` push runs were | |
| # cancelled within ~50s of starting — e.g. 31045605464 (49s), 31035630280 | |
| # (5s), 31019372072 (48s), 30978595567 (40s). That duration is the | |
| # discriminator: the shared workflow's `timeout-minutes: 45` cancellation lands | |
| # at 45m16s–45m28s, so these are concurrency kills. | |
| # | |
| # 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), SBOM (packages read), | |
| # and the Quality Report PR comment (issues / pull-requests write). | |
| # | |
| # A caller cannot grant a reusable workflow more than it holds itself, so | |
| # capping any of these at `read` makes the whole call fail to START: zero jobs, | |
| # no annotations — indistinguishable from "nothing to run". `development` at | |
| # cb101f1 shows exactly that, two `startup_failure` runs in a row. | |
| # | |
| # This block is the UNION of both branches' grants, which agreed on every entry | |
| # except `packages: read`. Dropping development's half would silently lose the | |
| # PR comment; dropping this branch's half would stop the pipeline starting. | |
| permissions: | |
| contents: write | |
| actions: write | |
| packages: read | |
| issues: write | |
| pull-requests: write | |
| jobs: | |
| quality: | |
| # A branch-creation push carries `github.event.created == true` and runs | |
| # with an empty `before` SHA; the shared workflow's diff-scoped gates have | |
| # nothing to compare against. Skipping it means the FIRST push to a new | |
| # branch does not produce a run that looks green because it ran nothing. | |
| if: github.event_name != 'push' || github.event.created != true | |
| uses: ConductionNL/.github/.github/workflows/quality.yml@main | |
| with: | |
| app-name: learniq | |
| php-version: "8.3" | |
| php-test-versions: '["8.3", "8.4"]' | |
| # THE LIST IS THE WHOLE DECLARED RANGE. This comment previously read | |
| # "appinfo/info.xml requires <nextcloud min-version="33"/>, so only | |
| # stable33+" — that floor is no longer 33. The file on this branch declares | |
| # <nextcloud min-version="32" max-version="34"/>, so stable32 is inside the | |
| # supported range and needs a leg: a reason that names a state of the world | |
| # rots the moment the world moves, and this one had. | |
| nextcloud-test-refs: '["stable34", "stable32", "stable33"]' | |
| enable-psalm: true | |
| enable-phpstan: true | |
| enable-phpmetrics: true | |
| enable-frontend: true | |
| enable-eslint: true | |
| enable-phpunit: true | |
| # Newman disabled: the collections hit `localhost:8080/apps/openregister/...` | |
| # (no `/index.php/` prefix → 404 under the CI PHP built-in server) and | |
| # assume the Scholiq OpenRegister register/schemas are already seeded. | |
| # Re-enable once the collection is rewritten to the CI base_url + a seed | |
| # step provisions the `scholiq` register. | |
| enable-newman: false | |
| # scholiq's info.xml declares <app>openregister</app>/<app>openconnector</app> | |
| # and Application.php registers listeners for OR lifecycle events; OR's | |
| # `development` carries ObjectCreatedEvent/ObjectTransitionedEvent + the | |
| # IMcpToolProvider interface the LearniqToolProvider tests need. | |
| # | |
| # openconnector is deliberately NOT installed here. It is a SOFT | |
| # dependency in src/manifest.json: scholiq proxies to it only for the LTI | |
| # 1.3 launch (LtiToolPlacementController) and PSP payment initiation | |
| # (PaymentTransactionController). Nothing else in the app touches it, so | |
| # its absence must degrade those two features, not block the shell. | |
| additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"}]' | |
| enable-sbom: true | |
| # ── E2E browser tests (Playwright) ─────────────────────────────────── | |
| # `enable-playwright` defaults to FALSE in the shared workflow, and this | |
| # caller did not set it AT ALL — not to `false` with a reason, just | |
| # absent. So the "E2E Tests (Playwright)" job has reported `skipped` on | |
| # every run this repo has ever produced, while tests/e2e/ grew to 34 spec | |
| # files / 433 tests. The Quality Report renders `skipped` the same way it | |
| # renders a pass, so none of that was ever visible as missing. | |
| # | |
| # `playwright-test-path` does double duty in the shared workflow: | |
| # 1. it is the directory the "Validate Playwright tests exist" step | |
| # counts *.spec.ts in; | |
| # 2. it is 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. | |
| # tests/e2e/playwright.config.ts is added in this commit precisely so | |
| # lookup (2) hits it. The run step passes no `--project`, so whichever | |
| # config it picks, every project in that config runs — and the ROOT | |
| # config's HTML report goes to `test-results/playwright-report`, which | |
| # matches NEITHER path the "Upload Playwright report" step collects. With | |
| # `if-no-files-found: ignore` that would have uploaded an EMPTY artifact | |
| # on a red run. The tests/e2e config writes the report to the app root | |
| # instead, which is the path the workflow actually uploads. | |
| enable-playwright: true | |
| playwright-test-path: tests/e2e | |
| # `playwright-seed-command` runs BEFORE Playwright starts, with the | |
| # Nextcloud server root as its cwd. | |
| # | |
| # It is set rather than left to global-setup.ts because `occ app:enable | |
| # scholiq` cannot be trusted to provision the register on its own: the | |
| # register-import repair step runs with no user session (OpenRegister | |
| # RBAC can deny it), swallows its own exception as "Non-fatal", `occ` | |
| # still exits 0, and the non-forced import advances the recorded version | |
| # WITHOUT applying the register. ci-seed.sh imports explicitly over the | |
| # admin API, parses the RESPONSE BODY (settings/load answers HTTP 200 | |
| # with success:false), seeds the example dataset, and then fails loudly | |
| # if the register, the six core schemas, the seeded-row floor or the | |
| # frontend bundle are not really there. | |
| # | |
| # The bundle gate is not redundant with the HTTP status: a missing | |
| # Nextcloud asset does NOT 404 — Nextcloud serves its own HTML error page | |
| # with HTTP 200 and Content-Type: text/html, so a status-code-only check | |
| # reads a completely broken frontend as success. The gate asserts the | |
| # content type is javascript. | |
| # | |
| # global-setup.ts detects the marker this script writes and skips its own | |
| # seed, so the import does not run twice. | |
| playwright-seed-command: 'bash apps/learniq/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 collection's requests hard-code URLs with no | |
| # `/index.php/` prefix (404 under the CI PHP built-in server) and assume a | |
| # seeded `scholiq` register. Turning it on would produce a guaranteed red | |
| # whose cause is already written down. Flipped back on in the commit that | |
| # rewrites the collection to the CI base_url and adds the seed step. | |
| # ── Frontend Check legs ────────────────────────────────────────────── | |
| # `frontend-checks` defaults to `[]`, and an empty list means the shared | |
| # workflow emits NO "Frontend Check" job at all — so this repo's validator | |
| # family ran nowhere while the run still looked complete. | |
| # `check:specs` is the aggregate (json-strict + manifest + register), | |
| # listed as ONE leg rather than three because each leg is a fresh job with | |
| # its own checkout + `npm ci`. | |
| # Measured on this tree before enabling: PASSES. It is enabled to keep it | |
| # passing, not because it is currently broken. | |
| # | |
| # `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, 131 of 136 tracked | |
| # frontend files in scope (docs/ excluded via .prettierignore; build | |
| # output via .gitignore, which prettier 3 also reads). | |
| # `check:l10n-js` regenerates l10n/<locale>.js from the JSON catalogue and | |
| # fails when the committed file is stale. Nextcloud serves ONLY the JS half | |
| # to a browser — raw JSON out of an app directory is a 404 — so a catalogue | |
| # that exists only as JSON renders English in the entire UI while every | |
| # server-rendered string is translated, with no error anywhere. | |
| # `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:specs", "format", "check:l10n-js", "check:schema-l10n"]' | |
| # ── Coverage ratchet ───────────────────────────────────────────────── | |
| # `enable-coverage-guard` defaults to FALSE, which is why both | |
| # "Coverage Baseline Protection" and "Coverage Baseline Check" have only | |
| # ever reported `skipped`. It needs two inputs this repo did not have, | |
| # both added in this commit: `scripts/coverage-guard.php` (byte-identical | |
| # to openregister's) and `.coverage-baseline` = 71.16, this repo's own | |
| # measured coverage (10878 of 15287 statements) read from clover.xml in | |
| # the `coverage-report` artifact of run 30911233564. | |
| enable-coverage-guard: true | |
| # ── 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. | |
| # | |
| # `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. | |
| # ── Cost controls (see ConductionNL/.github#596, #599) ─────────────── | |
| # | |
| # The fleet's CI was not slow, it was QUEUED. A Code Quality run does | |
| # ~38 job-minutes of work and its longest job is 16 minutes, but the | |
| # median run took 78.5 minutes wall clock: one poll on 2026-08-27 found | |
| # 53 jobs running against the account's 60-job ceiling, 1,528 queued | |
| # behind them, and a run that had waited 425 minutes to start. ~96% of | |
| # the wall clock was queueing, so the levers below remove DEMAND rather | |
| # than making any check faster. | |
| # Run PHPUnit and Playwright only when the diff could change their | |
| # verdict. They are 29 of the ~38 job-minutes a run spends, and 58% of | |
| # sampled fleet commits touched no .php/.js/.ts/.vue file at all. Fails | |
| # safe TOWARDS running, and a filtered skip is a declared state in the | |
| # Quality Report, distinct from both a pass and a missing verdict. | |
| enable-path-filtering: true | |
| # PR-time PHPUnit runs the primary PHP against the newest declared | |
| # Nextcloud; the full matrix still runs on every push to a default | |
| # branch and on the release PR into beta. Breadth moves from | |
| # per-commit to per-merge, it is not dropped. | |
| reduce-pr-matrix: true |