Skip to content

chore(sync): carry beta back into development #1449

chore(sync): carry beta back into development

chore(sync): carry beta back into development #1449

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:
# `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