Skip to main content

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​

WorkflowFileTrigger
CI.github/workflows/ci.ymlPush to main + every PR to main
Deploy Preview.github/workflows/deploy-preview.ymlEvery PR to main
Deploy Dev.github/workflows/deploy-dev.ymlAfter CI succeeds on main
Deploy Production.github/workflows/deploy-prod.ymlStable version tags (v1.2.3)
Notify Wiki.github/workflows/notify-wiki.ymlPush to main with wiki/** paths
Mobile Build.github/workflows/mobile-build.ymlManual only (workflow_dispatch)
Shotgun.github/workflows/shotgun.ymlManual 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:

  1. 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, the bundle:check transfer gate, and wiki:bundle:check (the generated Production Bundle page must match the build that just ran).
  2. storybook — bun run storybook:e2e. The Nx target builds the static Storybook through its declared dependsOn, then smoke-renders every story and runs play-function interactions in Chromium. Keeping build + runner in one job preserves the required dist/storybook filesystem handoff.
  3. docs — the currency gates for generated artifacts, which the affected vite:test sweep doesn't cover: nx test stretched-types, types:erd:test, wiki:test, stories:test, firebase:remote-config:test, storybook:visual:test, and tokens:test, followed by their generated-output checks.
  4. cypress — bunx nx e2e stretched-e2e. This starts its own dev server and runs the frozen Cypress suite through tools/cypress-e2e.ts; emulator-only specs skip because CYPRESS_EMULATED is not set (see Testing).
  5. 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/cache in non-production Bun jobs — package tarballs only, never node_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-playwright in Storybook and Playwright jobs, with separate exact keys because Storybook needs Chromium + headless shell while extension e2e needs full Chromium.
  • ~/.cache/Cypress in CI Cypress and deploy-preview jobs.
  • ~/.npm/_cacache in the Playwright job for generated Functions installation.
  • ~/.cache/firebase/emulators in the Playwright job, keyed by architecture and the locked Firebase CLI graph.
  • Gradle's ~/.gradle via a dedicated actions/cache in Android builds (the JDK 21 setup-java used to provide is image-baked).
  • build/SourcePackages only (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.gradle all 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​

ProjectUse
stretched-devDev hosting + Functions, PR preview channels
stretched-2c16aProduction

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.