Continuous Integration
Until September 2026 this repository had no CI. 117 Go test files and three bespoke frontend gates were run by hand or not at all, and deploy.ps1 blocks only on "does it compile and migrate" — so a commit that broke every test and every lint rule deployed cleanly.
The pipeline lives in .gitlab-ci.yml and runs on the instance runner (runner-vm-docker, linux/amd64), which accepts untagged jobs — so no job here declares tags:.
The jobs
| Job | Stage | Blocks? | What it proves |
|---|---|---|---|
frontend:check | check | yes | i18n:check, person-id:check, npm run lint |
backend:check | check | yes | go build ./..., go vet ./..., go test ./... |
backend:build | build | yes | compiles the API binary, kept as an artifact for the e2e job |
frontend:build | build | yes | npm run build — the type check and the bundle |
fixtures:check | check | parked | the fixture database is not behind the migrations on disk |
e2e | e2e | parked | the app actually works, in a browser, against a real database |
The two bespoke gates in frontend:check are the ones that have caught real shipped bugs: i18n-check (en/es/fr leaf-key parity, references to keys that do not exist, and whether the Go embedded UI catalog has gone stale) and person-id-check (a person-keyed route handed something that is not a person_id — a mistake that shipped five separate times). They are invoked individually because npm run build chains them ahead of vue-tsc -b and vite build, and those two do not fit in this container — see The runner is smaller than your laptop. Lint is a third step so that a type failure and a rule failure are never reported as each other.
fixtures:check and e2e are parked (when: manual, allow_failure): both need $FIXTURES_IMAGE, which has never been pushed — the project's container registry is empty, so every pipeline since the first failed there with runner_configuration_error before running a line of script. Publish the image (below) and delete the rules:/allow_failure: from both jobs to un-park them.
backend:check is hermetic. All 45 *_live_test.go files self-skip unless SIS_LIVE_DB_TEST is set, so no database is needed — and that leaves the tests which protect the permission model running for the first time: router/coverage_test.go (every route names either a permission node or an explicit exemption reason), nodes_test.go (that node exists in authz.KnownNodes), unauth_test.go (the surface reachable without a session is exactly the routes we think it is).
The runner's memory ceiling
This explained every red pipeline in the repository's first week, and it is not visible from anywhere else — so the jobs print a runner facts line first. Read it before reading anything else in a failed job.
runner facts: 2 cpu | cgroup memory.max=3221225472 | 7236MB total, 5378MB availableIt said 1073741824 — 1 GiB — until 11 September 2026, and at that size vue-tsc -b and vite build could not run here at all. Brayan Vizcaino raised it to 3 GiB that day and both went green with no change to this repository, because frontend:build derives its heap from whatever ceiling it finds.
Nothing in .gitlab-ci.yml sets that ceiling. It is memory (and memory_swap) under [runners.docker] in /etc/gitlab-runner/config.toml on the runner host, 10.50.90.27 — and being a GitLab administrator does not reach it, because container memory is not a GitLab setting at all: the admin API can pause, tag or delete a runner but not resize it. runner-vm-docker is an instance runner registered on 2026-08-20, before this pipeline existed, and serves every project on this GitLab, so changing it is a request to its owner and it moves the ceiling for everyone without lowering anyone's.
The host is not in DNS and is unreachable from a laptop (the GitLab server can reach it). Find its address again with — note /managers, since /runners/2 reports ip_address: null:
curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" https://gitlab.puc.edu/api/v4/runners/2/managersNode reports the symptom clearly (FATAL ERROR: Reached heap limit, exit 134); Go does not (compile: signal: killed, which is the kernel, not Go). Under the old ceiling the same backend:check passed on 9 September and was killed on 11 September on identical code.
backend:check carries GOFLAGS: -p=2 and GOMAXPROCS: 2 as a guard, not a proven cure: Go 1.24 takes GOMAXPROCS from cpu affinity rather than the cgroup, so a runner given more cpu without more memory would widen the compiler fan-out again silently. (Default parallelism also exhausted a 16-thread developer machine outright: "paging file is too small", then pageAlloc: out of memory.) go test inherits -p from GOFLAGS, so one number bounds the job. It was compiling close enough to the old 1 GiB ceiling to be killed once on code that had passed two days earlier; at 3 GiB it has room.
A mitigation that was tried and removed
resource_group briefly serialised the heavy jobs, on the theory that they were starving each other. The runner facts line disproved it: the limit is per container, and the host had 5 GB free the whole time. Serialising cannot prevent a cgroup OOM — it only made pipelines slower.
Measured: what the frontend toolchain needs
node --max-old-space-size=N, halved until it flips, against 217 .vue + 98 .ts files (11 September 2026):
| Step | Heap needed |
|---|---|
i18n:check, person-id:check | trivial |
eslint src | fits in 512 MB |
vue-tsc -b | >1024 MB, fits in 1536 |
vite build | >1024 MB, fits in 1536 |
All four fit under the 3 GiB ceiling. They did not under 1 GiB, where node would take only a ~512 MB heap and 768 MB was the most the job could ask for — which is why frontend:build owns the expensive two and ran allow_failure for a day. The numbers are kept because they say how much headroom the current ceiling actually buys.
frontend:build derives its --max-old-space-size from the cgroup ceiling at run time rather than hardcoding a number, which is why the runner's resize needed no commit: it logged --max-old-space-size=768 (from a 1024MB ceiling; needs ~1536) and then failed at 134 — cause and failure on adjacent lines — and now logs --max-old-space-size=2304 (from a 3072MB ceiling) and passes. A hardcoded 4096 in a 1 GiB container would have traded that clean V8 abort for a kernel OOM-kill (137), which says much less.
Nothing that was ever gated is lost meanwhile. npm run build is the full chain and developers run it, and sis_frontend/Dockerfile has always built the production image with bare vite build and no type check at all — which is why this never surfaced before CI existed.
The fixture database
The e2e job needs a populated database. It uses a purpose-built image containing a sanitized, PII-free copy of production, restored at image build time so a pipeline does not pay for a pg_restore of ~200 MB on every run.
# 1. Produce the sanitized artifact (see sanitize-db.md)
.\sanitize-db.ps1 -FromProd -Mode Fixtures
# 2. Build and publish the image
.\ci\fixtures-db\build.ps1 -Push-FromProd pulls the newest nightly backup of sis_prod off the server and brings the scratch database's schema current before sanitizing, which is what keeps fixtures:check below satisfiable — the artifact carries its source's migration ledger, and sis_prod can sit behind sis.
build.ps1 refuses a dump whose name is not sis_sanitized_*.dump unless you say why: a Demo-mode artifact carries manufactured photo rows and records demo on its manifest, so it is not interchangeable with a fixture. Pushing an image is not something you can take back: once a layer holding real data reaches a registry, deleting the tag does not delete what people have already pulled.
Three things about the image are load-bearing and commented in its Dockerfile:
- Postgres 18, not the 16 in the root
docker-compose.yml. A custom-format dump does not restore into an older major, and 18 is what production runs. PGDATAis outside/var/lib/postgresql/data. The official image declares that path as aVOLUME, so a fixture restored there during build is silently discarded when the layer is committed.ANALYZEruns at build time. Several report queries here have a documented planner cliff on stale statistics, and a fixture without it makes CI look slow for reasons that are not the code's fault.
Refreshing it is not a no-op
A restored dump carries core.schema_migrations with it, so the fixture believes its source's applied-set is its own. fixtures:check asserts that nothing on disk is missing from that ledger and fails with the list if it is.
It compares only files matching {NNN}[a-z]_*.sql, which is the pattern scripts/db-migrate.ps1 itself honours ("ignored (name does not match ...)"). A file outside it is never in the ledger, so a plain *.sql glob reports it as pending forever — and there is exactly one: add_item_course_id.sql, dead T-SQL from the SQL Server era ([dbo], sys.key_constraints) that was never applied to Postgres and never will be. Without that filter the job fails even on a freshly built fixture.
It asserts rather than migrating, deliberately: the runner (scripts/db-migrate.ps1) is 622 lines of PowerShell that cannot run in a Linux container, and reimplementing its ledger logic in shell would create a second, drifting implementation of the one thing that must not drift.
Expect the sanitizer's drift sweep to stop you too. It matches on column names, so any migration adding a PII-shaped column fails the refresh until someone classifies it — see sanitize-db. The first Fixtures run in this repo's history hit fourteen such columns.
End-to-end tests
Configuration is sis_frontend/playwright.config.ts; specs are in sis_frontend/e2e/.
# Locally: bring the stack up first, then
cd sis_frontend
npm run e2e # headless
npm run e2e:ui # interactive
npm run e2e:report # last run's HTML reportThe suite does not start a stack. The dev environment here is mostly native (Postgres on 5432, a hand-started sis-valkey container, the Go API under air, Vite), and a webServer block that half-owned that would be worse than owning none of it. Use start-go-dev.bat, and note that logins fail without sis-valkey because sessions live in Valkey.
The test identity
The suite signs in through POST /auth/dev-login, which mints a real session for any active account without a password — and which only exists when the API runs with ENVIRONMENT=development, where the route is not registered otherwise.
It signs in as a dedicated account seeded by ci/seed-e2e-user.sql, which is idempotent and safe to run against your own database:
psql -U postgres -d sis -f ci\seed-e2e-user.sqlThree parts of that script are less obvious than they look:
- The password is deliberately unusable.
passwordisNOT NULLso the row needs a value, and'!no-password-login'is not a valid hash of anything. That is the point: the account is reachable only through the dev-only endpoint, so there is no credential worth stealing. - The role is a manual override. Most role rows are derived from relationships and a nightly sweep removes derived rows that no longer have a basis. This account has none, so without
is_manual_overridethe sweep would correctly delete its role and the suite would start failing on permission gates for reasons unrelated to any change. - It creates a synthetic person and links it. This is not cosmetic. See below.
An account with no person_id cannot use the app
StudentHoldBanner renders inside AppLayout, so every authenticated page calls GET /registration/student-holds/my_holds. That handler returns 401 "Authentication required" when the caller's auth_user has no person_id — and the axios 401 interceptor treats any 401 as session death, calling logout() and redirecting to /login?expired=true.
So one endpoint's 401 destroys the whole session, on every page load, while /auth/me and /auth/user-roles/my_roles both return 200 the entire time. It presents as a session-timeout bug and is not one.
cmd/create-admin's -person-id flag is documented "(optional)". In practice it is not. As of September 2026 exactly one of 88,801 active accounts had a null person_id — the e2e account, before this was fixed.
What the specs cover
They exist to pin the three waves of UX Bill of Materials work that were applied across 350+ files by scripted edits and never opened in a browser:
recovery.spec.ts— an unknown URL and a denied node reach a screen that explains itself and offers a named destination, never a blank<body>accessibility.spec.ts— one visible<h1>per page, focus moving to it on every route change, the skip link, navaria-expanded, and the live region announcing result countsempty-state.spec.ts— a filtered-to-nothing list renders a titled empty state, and that it is actually styled (the dead-CSS sweep infd9338cbdeletedEmptyState's own stylesheet and nothing failed — not typecheck, not the build)list-state.spec.ts— search/page/sort in the URL, and a filtered list surviving a trip into a record and backsearch.spec.ts—Ctrl+K, grouped results, and a result opening a real pagetouch-targets.mobile.spec.ts— the 44×44px minimum, in themobileproject only, because that rule is a claim about the small viewport
Two failure modes worth recognising
A shortcut that "does not work." page.goto resolves on load, but views are lazy imports and AppHeader — where MenuSearch registers Ctrl+K — mounts only once that chunk resolves. Pressing the key first goes nowhere. Wait for button.menu-search-btn to be visible.
A test that skips instead of running. locator.count() does not auto-wait. Called straight after navigation it returns zero while the first page is still loading, so a count() === 0 guard skips the test silently. Use expect(locator).toBeVisible().
Identifier encryption in CI
config.go validates IDENTIFIER_AES_KEY / IDENTIFIER_HMAC_KEY unconditionally — not just in production — on the sound reasoning that a malformed key silently corrupts every identifier written with it. The e2e job therefore generates throwaway keys per run.
The consequence to know: the fixture's ciphertext was written with different keys, so the identifier-reveal path cannot decrypt anything in CI. A test that needs it must set up its own row.
What CI still does not do
- Nothing blocks a deploy on it.
deploy.ps1does not consult the pipeline; it gates on build and migration success only. - The browser suite has never run in CI.
e2eandfixtures:checkare parked on the missing fixture image, so nothing in the pipeline has yet opened the app. - The live-database Go suite does not run. 45 files self-skip. Wiring them to the fixture service is a small job and worth doing — with an assertion on the test count, because a skipped test and a passing one are indistinguishable in
go testoutput. - No Lighthouse, Storybook, or style linting yet. See the tooling plan.