One team, five apps.
Shipping, securing and watching them.
Auragika is a small family of apps: QuizClash, Pebble, Cadence, Grove and the auragika.com home. They started as separate side projects with separate habits. This is how we gave them one way of shipping, one security baseline and one way of seeing production, without a budget or a dedicated platform group, and what each piece caught.
Five apps, five habits
Each app began life the way side projects do: built fast, pushed to main, deployed by Netlify on every push. One had a single 2,000-line app.js. Another had 82 functions exposed on window because the markup called them from onclick attributes. One had production database rules that had drifted from the ones in the repo and let any signed-in user read and write shared data. None had a test that ran on a pull request.
Then they got real users, and they moved into one GitHub organisation. Two things followed. Netlify can't build private organisation repos on its free plan, so the "push and Netlify builds it" model stopped working overnight. And with five live apps, doing things five different ways was the main source of risk.
The goal was never "enterprise process for a side project". It was: any change to any app takes the same path, and that path makes the safe thing the easy thing. We built it on QuizClash first, because it was the most complex, then applied it to the others one app at a time.
The path from commit to production
The same path for all five apps. QuizClash has a server image in its release; the static apps ship a build number instead.
The pieces at the front are small and boring on purpose:
- A commit-msg hook enforces Conventional Commits locally (
feat(room): …,fix(sync): …), and a PR-title check enforces it again, because squash merges use the PR title as the commit. Release notes are then generated straight from merged PR titles. - A PR template with a "Risk and rollback" section that asks two questions every time: did the database rules change, and does deploy order matter? Those two questions prevented more incidents than any test (see the rollout order in Part 1).
- CODEOWNERS, Dependabot (monthly, grouped) and actions pinned by commit SHA, with read-only default permissions on every workflow.
CI that costs minutes only when asked
QuizClash's CI is heavy: build plus a leak check on the output, unit tests, a coverage gate, lint and duplicate-code detection, format, dependency audit, rules tests on the emulators, Go tests with the race detector and vulnerability scanning, and three browser suites. Private repos pay for Actions minutes, and a PR typically gets several pushes before it's ready.
So on a pull request, CI runs only when someone adds the run-ci label. The first job removes the label straight away, so the next run is another deliberate click. Every push to main runs everything regardless.
The interesting part is how merging is gated. Branch protection could require the CI jobs, but a job that is skipped counts as passing, and on a PR without the label everything is skipped. So the last job, report, posts a commit status called ci-passed, set to success only if every other job succeeded, and branch protection requires that status. A PR with no run, or with new pushes since its last run, has no status on its head commit and can't merge.
"Required checks" and "skipped jobs count as success" combine into a gate that is open whenever CI didn't run. Require an explicit status that only a successful run can produce.
Each app's CI is sized to the app. The static apps (Pebble, Cadence, Grove, the home site) run lint, format, unit tests, rules tests on the emulators, a production build, a secrets scan and an end-to-end suite in headless Chrome with the real Content Security Policy enforced. The home site, which has no npm at all, runs one job: build with warnings as errors, a static check of the output, then browser tests.
An AI reviewer that can say no
On a small team, a human second review on every PR soon turns into a rubber stamp or a queue. We made the second reviewer an AI, and made it a real gate (PRs #1, #5–#7):
- When
run-ciis added, Claude reviews the PR against the repo's own written standards (a "Reviewing pull requests" section in the project docs), comments inline on real problems, and writes a verdict: APPROVE or BLOCK. - On APPROVE, a follow-up step approves as
github-actions, and that approval counts toward the one required approval. On BLOCK it posts a summary review and the job goes red. - The model can read the PR and the repo and comment inline. It can't approve by itself; only the follow-up step can, and only on a structured APPROVE verdict.
- No verdict (an outage, a quota) means no approval, so a human approves instead. The gate fails closed.
Two things we learned getting there. The off-the-shelf review plugin skipped any PR it had already commented on, so after a push dismissed its approval it could never approve again; we replaced it with our own prompt. And a free-text verdict was unreliable to parse, so the verdict moved to structured output (PR #6).
It's not a replacement for thinking. It is a second reader that never skips the PR template, notices when a PR changes the rules without saying so, and reads CSS and workflow YAML as carefully as Go.
Merging isn't shipping
Our first automated pipeline deployed every green push to main. It was fast and it coupled two decisions that should be separate: "this change is reviewed" and "users get it now". For QuizClash it was also unsafe, because some changes need a manual rules publish between steps.
Now every merge to main publishes a release and deploys nothing (PR #10). For QuizClash, one tag covers both halves: the built site as a site.zip on the GitHub Release, and the server's container image with the same tag. Deploy is a workflow you run by hand. It downloads that release's artefacts and uploads them, so production gets exactly what CI built and tested. Nothing is rebuilt at deploy time.
- Keyless deploys. The deploy job authenticates to Google Cloud with Workload Identity Federation: GitHub's OIDC token is exchanged for short-lived credentials. There is no service-account key in the repo, the secrets, or anywhere else.
- One production queue. Deploy and Rollback share a concurrency group, so two deploys never overlap.
- Deployments you can find. Each deploy appears under the repo's Deployments with a link to the live site.
The static apps follow the same model with build numbers (Pebble PR #14). Every deploy gets the workflow's run number, and that number appears everywhere you'd look: the Deployments list ("Build 12 · abc1234"), the Actions run name, a build-12 tag on the commit, the hosting provider's deploy title, and the app itself. The run summary says what's new since the previous build and the exact ref to roll back to.
When someone reports a bug, the first question is "which build?". Every Auragika app shows its version and build quietly (an About screen, an account menu footer, a pill in the site footer) and puts it in a meta tag. Nobody has to guess.
Rollback that asks what's really live
A rollback button is only as good as its answer to "what was live before this?". The naive answer, "the previous GitHub Release", is wrong as soon as you've skipped a release or rolled back once already.
QuizClash's Rollback (PR #24) asks the platform instead. Each Cloud Run revision is labelled with the release it came from. Labels can't contain dots, so v2026.10.24-55 is stored as v2026_10_24-55. Without the label, a revision only records an image digest, and that digest is the platform-specific manifest rather than the tagged index, so it can't be reliably mapped back to a tag.
Rollback reads the current release from the service, picks the newest revision of an older release, and runs Deploy with it: server first, then site. Running it again steps further back, never forward. It also warns when the database rules differ between the two releases, because rules are published by hand and a code rollback doesn't roll them back.
Before trusting it, we ran the selection script read-only against production and checked it picked the right pair.
One security baseline
Each app got the same sequence of security work, in roughly the same order. The order matters: lock down data first, then make the code safe to change, then reduce what a client can do at all.
| Step | What it means | Example |
|---|---|---|
| 1. Lock down the data | Rules in the repo, tested on the emulator, and actually published | Grove's live rules had drifted from the repo and let any signed-in user read and write shared data. The repo version had never been published. Fixed and tested before anything else. |
| 2. Don't store what others can read | If a collection must be readable by members, it must not hold private fields | Grove profiles had to be readable for member names, so every user could read every email. Emails stopped being stored, and the rules now only allow removing one. |
| 3. Bound what one user can create | Rules cap sizes, shapes and ranges per user | Pebble's month documents must be named after a real month in a bounded range, instead of any pattern that looked like one. |
| 4. No inline JavaScript, strict CSP | Every onclick becomes a data-click action; script-src 'self' | Pebble had 34 inline handlers, Cadence 82 functions on window. A build check now fails on any inline script or handler. |
| 5. Fingerprint and pin assets | One bundled, hashed script with Subresource Integrity; third-party libraries self-hosted | Pebble self-hosts its chart library, and Summary still works if it fails to load. |
| 6. Move trust off the client | Anything that decides an outcome runs where the user can't edit it | QuizClash's scoring, rounds and AI conversion moved to the Go server (Part 1). |
Grove needed one more step because it stores private things. Protected vaults are readable only by their creator, enforced in the rules, not just hidden in the UI; their item titles are encrypted with the content; and the PIN check moved from a fast hash to a value encrypted with a PBKDF2-derived key, so every guess costs a full key derivation. Chat was disabled inside protected vaults, because the chat rules can't tell whether a vault is protected.
Every one of these PRs follows the same shape: the risk in one sentence, the change, the rules tests that prove the abuse case is refused, and whether the rules need publishing and in which order. We describe the classes of fix here and leave the exact rules in the repos.
Seeing production on a free tier
Once QuizClash had real traffic, we couldn't see it: how many games were played, on which topics, and whether the API was failing or slow. We wanted monitoring that cost nothing and lived in the repo (ADR 0006).
- Structured logs are the source. The Go server already wrote one JSON line per request. It gained the matched route pattern (so metrics group by endpoint, not by room code) and a handful of game events, such as room created, match finished and host changed. Events carry no user ids, names or room codes, and a test checks that.
- Log-based metrics, dashboard and alerts as code. A script applies the metric definitions, alert policies, an uptime check and the dashboard JSON from the repo, and is safe to re-run. Alerts cover server errors, slow game endpoints, AI failures and the health check.
- A runbook per alert. Each alert's documentation says what it means, what to check first and what to do. An alert without a runbook is just a notification.
- Free-plan limits as signals. A Live section shows players online and rooms in use next to the free plan's limits, so we'd see a limit coming before users hit it.
Then visits. A third-party, cookieless analytics script missed a noticeable share of visitors, because ad blockers (and some browsers by default) block it. So we count visits first-party, on the same dashboard (PR #39):
export function reportVisit(flags) { // a few booleans, never an id fetch(VISIT_URL, { method: 'POST', // text/plain is a "simple" request: no CORS preflight, one round trip headers: { 'Content-Type': 'text/plain' }, body: JSON.stringify(flags), keepalive: true, // survives navigation }).catch(() => {}); // fire and forget }
The server accepts only a fixed set of booleans (anything else is rejected) and writes one log line with no ids. It's a public endpoint, so anyone can inflate the count; the docs say to treat a jump in visits without a matching jump in players online as noise. And there was a side benefit we hadn't planned: the beacon wakes the scaled-to-zero server on page load, so the first game request is less likely to hit a cold start.
Making five apps feel like one
The last layer is the one users see. All five apps share one design system: dark-first, one accent colour per app, the same type, the same spacing and motion rules, light mode that can follow the system. It's kept as a single stylesheet that each app copies unchanged, with app-specific styles built only from its tokens. Updating the theme means replacing the whole file, never editing a copy.
Shared patterns moved between apps the same way: the splash that slides into the sign-in card or straight into the app, the "use it like an app" install hint (one tap on Chrome, the Share → Add to Home Screen steps on iPhone), the account menu with a confirmed sign-out, and the quiet version line. Each landed in one app first, then the PR for the next app said "as in Pebble".
The home site, auragika.com, ties them together, and it holds itself to the same rules as the apps: a strict CSP, fingerprinted assets, a build check and browser tests. So does this portfolio, as of this month.
Most of this was implemented with an AI coding agent (Claude Code), steered by written standards: per-repo CLAUDE.md files, ADRs, and reusable skills for the theme and for Firebase PWAs. Writing the standard down once is what made it possible to apply it to five apps consistently.
Takeaways
See the family
All the apps are listed on auragika.com. They install like apps and open offline.