Skip to content

ci(quality): add a standalone frontend build gate and hand its output to the Playwright job - #133

Merged
rubenvdlinde merged 1 commit into
mainfrom
ci/frontend-build-gate
Aug 3, 2026
Merged

ci(quality): add a standalone frontend build gate and hand its output to the Playwright job#133
rubenvdlinde merged 1 commit into
mainfrom
ci/frontend-build-gate

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Why

npm run build lived in exactly two places in quality.yml — inside the Playwright job and inside the journeydoc capture job — and both are opt-in and default OFF.

Surveying all 21 repos that call this workflow: 2 enable Playwright (doriath, pipelinq) and 0 enable journeydoc capture. So for 19 repos the frontend has never been compiled in CI at all. A repo could ship a bundle that does not build and every gate stayed green, because nothing in the pipeline ever ran webpack.

That is not theoretical. hermiq's build broke when the floating node-polyfill-webpack-plugin: ^4.0.0 resolved to 4.1.0 and webpack emitted 25 errors — with CI green throughout. Nine repos still carry an unpinned caret on that same plugin, so any lockfile regeneration reproduces it silently:

caret repos
^4.0.0 openbuild, petstore, portaliq, shillinq, nextcloud-app-template
^4.1.0 pipelinq
^3.0.0 decidesk, zaakafhandelapp

What changes

1. New frontend-build job

Modelled on the existing auto-detected frontend-testsno opt-in input. It runs npm ci && npm run build when enable-frontend is true and the repo has a package.json with a build script plus a package-lock.json, and it blocks on failure through the Quality Report gate.

Repos missing any of those skip cleanly — and unlike frontend-tests they skip before setup-node, so a repo without a lockfile cannot be newly broken by this job.

timeout-minutes: 30. The job has never existed, so this is sized from its embedded proxies: npm run build measured at 211 s under heavy load, and the frontend-checks legs that wrap it peak at 2.1 min end to end. 30 min is ~8x the worst observed build. Deliberately generous — a bound that fires under normal contention produces a cancelled job, and a cancelled job returns no verdict at all, which is strictly worse than no bound.

2. Playwright consumes the artifact instead of rebuilding

The output is uploaded as frontend-build-output (tarred, 1-day retention) and the Playwright job restores it in place of its own identical npm run build.

The hand-off is a fast path with a full fallback, never a replacement:

  • the download is continue-on-error, keyed off a job output that is 'true' only when the build ran and produced files;
  • the restore asserts the tarball is non-empty and actually contains JavaScript before extracting;
  • anything else — no artifact, empty artifact, no .js, or a repo with a non-default frontend-path — falls through to the pre-existing build step, byte for byte.

That asymmetry is deliberate. A missing or wrong bundle does not 404 — the front controller answers 200 text/html, so a status-code probe reads it as success and the only symptom is a selector timeout naming an element. The repos whose ci-seed.sh asserts the response is really JavaScript keep that guard meaningful, because the reuse path proves the same thing before it is taken. Cost of the fallback: ~3 minutes. Cost of a bad hand-off: a false verdict on every UI spec in the fleet.

The output location is discovered, not assumed: a timestamp marker before the build, then everything the build touched outside node_modules/.git. Nextcloud apps emit to js/, vite apps to dist/, portaliq writes two bundles from a composite script — hardcoding any of those is exactly how this would ship an empty bundle.

What this change cannot break

  • It adds no URL and no path assumption. Nothing here references /apps/<app> or /custom_apps/<app>. The tarball extracts into the app checkout at the exact relative paths the build wrote.
  • frontend-build is in playwright.needs only so the artifact is downloadable — it is not a gate. The Playwright job's if: contains !cancelled(), which suppresses the implicit success() on needs, so a failed or skipped frontend-build still lets Playwright run and reach its own verdict.
  • No existing job's steps, order or conditions change, apart from the added if: on Playwright's build step — whose false branch is today's behaviour.
  • Repos that do not call this workflow (openconnector, softwarecatalog, procest, nldesign run their own standalone code-quality.yml) are untouched.

Verification

Both are throwaway PRs on new branches — genuinely new runs, not re-runs, since a re-run replays the original workflow resolution and would not exercise this change at all.

Blast radius

All 21 callers pin quality.yml@main, so this takes effect everywhere on merge. Expected: the gate runs on 20 repos and skips 1 (hydra — enable-frontend: false, no package.json). Any repo whose frontend does not currently compile will newly go red. That is the point of the change, and it is a true finding rather than a regression, but it is a real change in fleet verdicts.

… to Playwright

`npm run build` lived in exactly two places in this workflow — inside the
Playwright job and inside the journeydoc capture job — and both are opt-in
and default OFF. Of the 21 repos that call quality.yml, 2 enable Playwright
(doriath, pipelinq) and 0 enable journeydoc capture. So for 19 repos the
frontend has never been compiled in CI at all: a repo could ship a bundle
that does not build and every gate stayed green, because nothing in the
pipeline ever ran webpack.

hermiq is the proof. `node-polyfill-webpack-plugin: ^4.0.0` floated to
4.1.0 and webpack emitted 25 errors; CI had nothing to say. Nine repos
still carry an unpinned caret on that same plugin, so any lockfile
regeneration can reproduce it silently.

New `frontend-build` job, modelled on the existing auto-detected
`frontend-tests`: no opt-in input, runs `npm ci && npm run build` when the
repo has a package.json with a `build` script, blocks on failure, and is
wired into the `Quality Report` gate. Repos without a package.json, without
a lockfile, or without a `build` script skip cleanly — and unlike
frontend-tests they skip *before* setup-node, so a repo without a lockfile
cannot be newly broken by this job.

timeout-minutes: 30. The step has never run standalone, so this is sized
from its embedded proxies: `npm run build` measured at 211 s under heavy
load, and the frontend-checks legs that wrap it peak at 2.1 min end to end.
30 min is ~8x the worst observed build. Deliberately generous — a bound
that fires under normal contention yields a CANCELLED job, which returns no
verdict at all, which is strictly worse than no bound.

The build output is uploaded as `frontend-build-output` and the Playwright
job now restores it instead of running its own identical build. The
hand-off is a fast path with a full fallback, never a replacement:

  - the download is `continue-on-error`, keyed off a job output that is only
    'true' when the build ran AND produced files;
  - the restore asserts the tarball is non-empty and actually contains
    JavaScript before extracting;
  - anything else — no artifact, empty artifact, no .js, or a repo with a
    non-default `frontend-path` — falls through to the pre-existing build
    step, byte for byte.

That asymmetry is deliberate. A missing or wrong bundle does not 404: the
front controller answers 200 text/html, so a status-code probe reads it as
success and the only symptom is a selector timeout naming an element. The
repos whose ci-seed.sh asserts the response is really JavaScript keep that
guard meaningful, because the reuse path proves the same thing before it is
taken. Cost of the fallback: ~3 minutes. Cost of a bad hand-off: a false
verdict on every UI spec.

What this cannot break, explicitly:

  - It adds no URL and no path assumption. Nothing here references
    /apps/<app> or /custom_apps/<app>; the tarball is extracted into the app
    checkout at the exact relative paths the build wrote, discovered with a
    timestamp marker rather than a hardcoded js/ or dist/.
  - `frontend-build` is in playwright's `needs` only so the artifact is
    downloadable. The job's `if:` contains `!cancelled()`, which suppresses
    the implicit success() on needs, so a failed or skipped frontend-build
    still lets Playwright run and reach its own verdict.
  - No existing job's steps, order or conditions change apart from the added
    `if:` on Playwright's build step, whose false branch is the current
    behaviour.

Verified: actionlint clean (and positive-controlled — an injected bad
`needs` is reported, so the file is genuinely being checked); shellcheck
findings unchanged at 14 before and after; YAML re-parsed, job count 17 -> 18
and report.needs 14 -> 15, exactly +1 each; the package/restore tar
round-trip exercised locally including the negative control that a tarball
with no .js sets restored=false and falls back to building.
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Live verification on the branch — both controls fired

Two throwaway PRs pinned quality.yml@ci/frontend-build-gate on new branches, so these are genuinely new runs. (A re-run replays the original workflow resolution and would not have exercised this change at all.)

Happy path + artifact hand-off — ConductionNL/keepiq#141 (closed unmerged)

Run 30831935876. doriath is the only repo in the fleet whose Playwright job is currently green, so it is the one target where a regression would be visible.

The Playwright job's step outcomes are the actual evidence — a green E2E alone would not prove the reuse happened:

success  Download prebuilt app frontend
success  Restore prebuilt app frontend
skipped  Build app frontend          <-- it did NOT rebuild
success  Run Playwright tests
Artifact holds 51 file(s), of which 18 are .js
Restored 51 file(s) from the frontend-build artifact into server/apps/doriath.
Front-controller check: /apps/doriath/ -> HTTP 401
OCS entry-point check: /ocs/v2.php/cloud/user -> HTTP 401

Controlled against the identical head SHA. Baseline run 30822156638 ran on e13a305 — byte-for-byte the SHA this test branch was cut from — and built in-job:

baseline (builds in-job) this branch (reuses artifact)
Playwright 56 tests, 51 passed, 2 flaky ✅ 56 tests, 52 passed ✅
failing jobs stylelint, psalm, phpmd, phpstan, phpcs, Quality Report identical set
job count 24 26

Zero new failures. The job-count delta is fully accounted for: Frontend Build (this change) and Hydra Gates (PR-only, absent from the push-triggered baseline) — nothing else moved.

Negative control — ConductionNL/petstore#15 (closed unmerged)

Run 30832016276. petstore main was all-green at run 30822552075 (30 jobs, zero failures), so any red here is attributable.

Injected import './this-module-does-not-exist.js' into src/main.js:

failure  Build frontend
skipped  Package build output
skipped  Upload build output
Module not found: Error: Can't resolve './this-module-does-not-exist.js'
  in '/home/runner/work/petstore/petstore/src'

quality / Frontend Buildfailure, and it took quality / Quality Report red with it. The gate is live, not a gate whose absence looks like its success.

Both test branches have been deleted.

@rubenvdlinde
rubenvdlinde merged commit 835918f into main Aug 3, 2026
4 checks passed
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Post-merge verification — 19 fresh workflow_dispatch runs off @main

Merged as 835918f at 2026-08-03T16:40:56Z. Every run below was dispatched after that, so each is a genuinely new resolution of quality.yml@main rather than a re-run.

Merged file re-checked from main: 18 jobs, frontend-build present, timeout-minutes: 30, playwright.needs = [php-quality, security, frontend-build], report.needs 15, actionlint clean.

The artifact hand-off, on both Playwright repos

Only 2 of 21 callers enable Playwright — doriath and pipelinq. (The brief named opencatalogi, docudesk and planix; none of the three sets enable-playwright, and openconnector/softwarecatalog/procest/nldesign do not call this workflow at all — they run standalone code-quality.ymls.)

repo Frontend Build Download Restore Build app frontend Playwright
doriath 30833303244 skipped ✅ 56 tests, 53 passed (4.8m)
pipelinq 30833307592 skipped pre-existing red, see below

doriath's own history is the control: 56 tests / 51 passed / 2 flaky when it built in-job, 56 / 53 reusing the artifact.

pipelinq's E2E was already red before this change — run 30830273021 at 16:02, 38 minutes pre-merge: 211 tests, 61 failed, on ObjectService::find(): Argument #2 ($_extend) must be of type ?array, string given at Customer360Controller.php:142. An app bug, not a bundle problem.

Fleet coverage

outcome n repos
Frontend Build 13 doriath, pipelinq, openregister, opencatalogi*, docudesk, planix, decidesk, launchpad, openbuild, petstore, portaliq, scholiq, shillinq, zaakafhandelapp
Frontend Build 3 deskdesk, app-versions, nextcloud-app-template
skipped / not observed 4 hydra, mydash, larpingapp, opencatalogi@main

All three failures are npm ci, not npm run build, and none changes a repo's verdict — every one of those runs was already red from Vue Quality, Security (npm) and License (npm), which run the same npm ci:

  • deskdeskERESOLVE, @nextcloud/eslint-config@8.4.2 vs @typescript-eslint/utils@7.18.0
  • app-versions — lockfile out of sync, Missing: picomatch@4.0.5 from lock file
  • nextcloud-app-template — lockfile out of sync, Missing: text-table@0.2.0 from lock file

petstore went fully green end to end with the new job in place.

Not caused by this change (verified, not assumed)

  • opencatalogi@main / hydra@main → startup_failure. Their callers declare a permissions: ceiling narrower than the shared workflow's contents: write / actions: write jobs, which GitHub validates statically even for disabled jobs. Those declarations are in 00c11a1, before this PR, and this commit adds zero permissions lines. Proof rather than assertion: dispatching opencatalogi on development — whose caller has the wide ceiling — against the very same merged @main gives a fully successful 27-job run (30833733008) with Frontend Build ✅. hydra has been startup_failure since 08:33, 8 hours before the merge.
  • mydash — run queued 30+ minutes and never picked up a runner; last successful run was in May. Unobserved, unrelated.
  • larpingapp — its caller has no workflow_dispatch trigger, so it could not be dispatched. The gate will run on its next push or PR.

The build row renders in the Quality Report and result-frontend-build is downloaded by it.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Correction / addendum — pipelinq's Playwright job was cancelled at the 45-minute cap

Run 30833307592: Run Playwright tests ran 16:46:09 → 17:28:51 and was cancelled by the pre-existing timeout-minutes: 45. A cancelled job returns no verdict at all, so this is worth stating rather than filing under "pre-existing red".

Not caused by this change, and the evidence is in the step timings. The whole setup phase — checkout, Nextcloud install, npm ci, Playwright install, artifact download, restore — took 2m10s, of which this change accounts for 3 seconds (Download prebuilt app frontend 2s, Restore prebuilt app frontend 1s), and Build app frontend was skipped. The hand-off made this job faster, not slower.

Artifact holds 41 file(s), of which 41 are .js
Restored 41 file(s) from the frontend-build artifact into server/apps/pipelinq.
Running 232 tests using 1 worker

pipelinq development was already being cancelled at this cap before the merge. Its Playwright job on that branch:

run started ended outcome
30801841437 10:09 11:21 failure (1.2h — before the cap landed)
30801846718 09:37 10:50 failure (1.2h)
30816327084 13:07 13:53 cancelled at 45m
30816330253 13:10 13:56 cancelled at 45m
30833307592 16:43 17:28 cancelled at 45m (this one, 3.5h after those)

The 16:02 baseline I quoted earlier ran 211 tests using 6 workers in 17.8 min, but that was branch ci/enable-shared-e2e. development runs 232 tests using 1 worker, which does not fit in 45 minutes on any run today, merge or no merge.

So doriath is the one repo where the artifact hand-off can be shown end-to-end against a green Playwright suite, and it is green. On pipelinq the hand-off demonstrably works (download ✅, restore ✅, build skipped) but the suite itself cannot reach a verdict inside its cap — a pre-existing pipelinq problem, worth its own issue: a 1-worker 232-test suite against a 45-minute bound.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant