CI CD and Deploys
See also: Testing · Secrets Management · Mobile App · Wiki Publishing
All pipelines live in .github/workflows/. Runners install deps with bun install --frozen-lockfile (Bun + Node 22).
Runners
All Linux jobs run on ephemeral, user-namespaced ARC pods: anyhost01 supplies Bun and build tooling, while browser and Android lanes use anyhost01-ui for Chromium, Playwright, Cypress, Xvfb, and the Android SDK. They do not use a workflow container: directive because ARC runner pods have no Docker daemon. Browser jobs keep a fail-fast runtime guard (Cypress lanes probe Xvfb/libnspr4 inline; Playwright lanes run tools/check-playwright-libs.ts): OS package repair belongs to the runner-image build, not a CI job, so a missing lib means the rollout is incomplete and the job stops with a pointer to the image instead of attempting apt-get mid-run (playwright install --with-deps is deliberately not used). Deploy jobs (deploy-prod, deploy-dev, deploy-preview, and mobile-build.yml's android lane) pin Bun from .bun-version via setup-bun like the CI gate does, so a release is never built with a drifted image-baked Bun. Every ARC job runs .github/actions/bun-drift-warning before setup-bun: when the image-baked Bun differs from .bun-version it emits a workflow warning telling you the runner image needs a rebuild — setup-bun alone would silently mask the drift. .github/workflows/arc-smoke.yml verifies the runner-image contracts; the runner images themselves are built and published from the anyhost-IaC repo (images/actions-runner/Dockerfile, slim + full stages). The one exception is mobile-build.yml's ios job: xcodebuild needs macOS, so it stays on GitHub-hosted macos-latest. Custom runner labels are declared in .github/actionlint.yaml.
Overview
| Workflow | File | Trigger |
|---|---|---|
| CI | .github/workflows/ci.yml | Push to main + every PR to main |
| Deploy Preview | .github/workflows/deploy-preview.yml | Every PR to main |
| Deploy Dev | .github/workflows/deploy-dev.yml | After CI succeeds on main |
| Deploy Production | .github/workflows/deploy-prod.yml | Stable version tags (v1.2.3) |
| Notify Wiki | .github/workflows/notify-wiki.yml | Push to main with wiki/** paths |
| Mobile Build | .github/workflows/mobile-build.yml | Manual only (workflow_dispatch) |
| Shotgun | .github/workflows/shotgun.yml | Manual only (workflow_dispatch) |
notify-wiki.yml does not host the wiki itself — it fires a
repository_dispatch (event_type=wiki-updated) to fun-language/Stretched-wiki,
which builds and serves the public site; see Wiki Publishing. The wiki/ folder
is still the source of truth, still gated by the docs job (wiki:check) —
notify-wiki.yml runs a production build (without it wiki:build skips
generated/Production-Bundle.md), then re-runs wiki:build and requires
an empty git status --porcelain over wiki/ before dispatching, so a
push that bypassed CI (or skipped the hook's auto-regen) cannot publish a
snapshot the gate rejects.
CI (ci.yml)
Five independent jobs, concurrency-grouped per ref (a new push cancels the in-flight run). None waits for another, so GitHub can schedule every lane immediately:
- test — prettier
--check,nx affected --target=lint,nx affected --target=vite:test --coverage, Firebase Functions tests (bun run test:functions:coverage— coverage on, so the pinned thresholds gate), a production web build, thebundle:checktransfer gate, andwiki:bundle:check(the generated Production Bundle page must match the build that just ran). - storybook —
bun run storybook:e2e. The Nx target builds the static Storybook through its declareddependsOn, then smoke-renders every story and runs play-function interactions in Chromium. Keeping build + runner in one job preserves the requireddist/storybookfilesystem handoff. - docs — the currency gates for generated artifacts, which the affected
vite:testsweep doesn't cover:nx test stretched-types,types:erd:test,wiki:test,stories:test,firebase:remote-config:test,storybook:visual:test, andtokens:test, followed by their generated-output checks. - cypress —
bunx nx e2e stretched-e2e. This starts its own dev server and runs the frozen Cypress suite throughtools/cypress-e2e.ts; emulator-only specs skip becauseCYPRESS_EMULATEDis not set (see Testing). - playwright — verifies the image-baked full Chromium (JDK 21 for the emulators also comes from the image), then runs
bun run e2e:playwright. Its wrapper builds web/mobile/extension/Functions, starts and seeds a fresh emulator, serves all three surfaces, runs the parallel Playwright projects, and owns cleanup.
The former e2e needs: test edge carried no artifact or service and serialized the two slowest lanes, so it was removed. Cypress and Playwright also use separate runners now; fixed ports and emulator state cannot collide across their VMs.
CI's gate jobs and .husky/pre-push are held in lockstep by bun run hook:parity (tools/ci-hook-parity.spec.ts, run by both sides): a run: command added to test, storybook, or docs must land in the hook too, or in the spec's CI_ONLY allowlist with a reason. The dedicated cypress and playwright browser jobs are whole-job exemptions. The story catalog is auto-regenerated by .husky/pre-commit — see Tooling and Scripts.
Test parallelism remains deliberately asymmetric: every Vitest config uses maxWorkers: '100%' so each active project gets all cores, while the CI test job sets NX_PARALLEL=1 so only one coverage-heavy project owns the 4-vCPU runner at a time. This avoids the 2026-07-31 whole-box GC/swap failure. Do not add a hardcoded worker count or increase cross-project Nx parallelism on that runner without a new memory benchmark. GitHub-job fan-out is the safe parallelism boundary because each job gets a separate VM.
CI caches and pinned tools
Bun (.bun-version) and the resolved Playwright/Cypress versions from bun.lock are baked into the ARC runner images, so no job floats on latest or re-downloads toolchains. firebase-tools is an exact dev dependency in package.json/bun.lock, so CI and deploys use the same reviewed CLI instead of a fresh global or @latest install.
Caches are narrow and versioned by OS/architecture plus their owning lock input:
~/.bun/install/cachein non-production Bun jobs — package tarballs only, nevernode_modules. Production deploy intentionally restores no dependency cache because it is secret-bearing; its frozen lockfile install keeps caches outside the production trust chain.~/.cache/ms-playwrightin Storybook and Playwright jobs, with separate exact keys because Storybook needs Chromium + headless shell while extension e2e needs full Chromium.~/.cache/Cypressin CI Cypress and deploy-preview jobs.~/.npm/_cacachein the Playwright job for generated Functions installation.~/.cache/firebase/emulatorsin the Playwright job, keyed by architecture and the locked Firebase CLI graph.- Gradle's
~/.gradlevia a dedicatedactions/cachein Android builds (the JDK 21 setup-java used to provide is image-baked). build/SourcePackagesonly (not all DerivedData) in manual iOS builds.
Cache actions expose restore/hit/save timings in each job log. Large browser caches must be removed if warm-run restore time is not lower than the install time they replace.
Deploy Preview (deploy-preview.yml) — ephemeral PR environments
On every PR: builds the app (development config), Storybook, and the wiki site (bun run wiki:site), then deploys all three to Firebase Hosting preview channels on the stretched-dev project via FirebaseExtended/action-hosting-deploy — channel pr-<number>, targets app, storybook, and wiki (site stretched-dev-wiki; the wiki documents internals — unlisted-URL exposure accepted 2026-08-02, see Wiki Publishing), expiring after 1 day. The workflow disables the action's automatic comments and maintains a marker-delimited Preview environments table in the PR description instead, preserving the rest of the description when URLs are refreshed.
Finally it runs the Cypress suite against the deployed preview URL: CYPRESS_BASE_URL=<details_url> bunx nx e2e stretched-e2e. tools/cypress-e2e.ts sees the external base URL and skips starting a dev server (see Testing).
Auth: secrets.FIREBASE_SERVICE_ACCOUNT_DEV (a service-account JSON in GitHub Actions secrets — distinct from runtime secrets, which live in Firebase Secret Manager; see Secrets Management).
Deploy Dev (deploy-dev.yml)
Runs automatically after a successful CI run on main (workflow_run, gated on conclusion == 'success'; non-cancelling deploy-dev concurrency group). Checks out the exact CI head SHA, builds the app (development), Functions, and Storybook, then:
bunx firebase deploy --only hosting:app,hosting:storybook,functions --project stretched-dev
authenticated via google-github-actions/auth with FIREBASE_SERVICE_ACCOUNT_DEV.
Deploy Production (deploy-prod.yml)
Runs when a stable semantic-version tag with the exact v<major>.<minor>.<patch> format (for example, v1.2.3) is pushed. GitHub's production environment must require approval, and a tag ruleset must protect matching release tags from unauthorized creation, updates, or deletion (tracked in NEXT-STEPS.md). The workflow rejects leading-zero, prerelease, and malformed versions; verifies that the current remote tag still targets the event commit and that the commit is on main; then builds the app and Functions with production configs and deploys hosting:app,functions to stretched-2c16a using secrets.FIREBASE_SERVICE_ACCOUNT. The qualified hosting:app target intentionally excludes Storybook, which is not built or deployed in production. After a successful deployment, it verifies the tag target again and idempotently creates the matching GitHub release with GitHub-generated notes summarizing merged pull requests and commits since the previous release.
Mobile Build (mobile-build.yml)
Manual only — never runs automatically, so runner minutes (macOS bills at 10×) are only spent on demand. Inputs: environment (dev/prod) and platform (android/ios/both).
- android (self-hosted Linux + the ARC runner image — JDK 21 Temurin, Android SDK platform 36 / build-tools 36.0.0 per
variables.gradleall image-baked; Gradle deps via a dedicated cache):bun run mobile:build:<env>→bunx cap sync android→./gradlew assembleDebug→ uploads the debug-signed APK as an artifact (14 days). No release keystore exists yet. - ios (macos, narrow Swift package source cache): builds for the simulator with
CODE_SIGNING_ALLOWED=NO— validates the native pipeline; a store-ready IPA needs Apple certs first. Uploads the simulator.app.
The same APK can be built locally without runner minutes via bun run mobile:apk:dev — see Mobile App.
Shotgun (shotgun.yml)
Manual EXPERIMENT lane — not a merge gate. Runs the whole pre-push graph
(tools/verify.ts, see Tooling and Scripts) on one runner with
configurable concurrency: gh workflow run Shotgun for unlimited (true
shotgun), -f cap=3 for a capped graph. Exists to measure how
graph-parallelism behaves on CI hardware: ci.yml's gating test job is
deliberately SERIAL (NX_PARALLEL=1) because in-box parallelism is what
wedged the 4-vCPU runner on 2026-07-31 (Testing). If a capped graph
proves reliably faster here, promoting it into ci.yml is a deliberate
follow-up — never a side effect. Carries the standard 30-minute budget and
the per-process heap ceiling (a contended run dies fast with a V8 trace
instead of wedging).
Firebase projects
| Project | Use |
|---|---|
stretched-dev | Dev hosting + Functions, PR preview channels |
stretched-2c16a | Production |
Hosting targets (app, storybook) and the Functions source (dist/apps/firebase-functions) are defined in firebase.json. Their project-specific Hosting site mappings are committed in .firebaserc: dev maps both app and storybook, while production maps only app to the stretched-2c16a site.