NNO CI/CD Architecture
Documentation for NNO CI/CD Architecture
Blueprint for the continuous integration and deployment pipeline. All NNO services and apps are deployed via GitHub Actions using a self-hosted runner on DigitalOcean.
1. Infrastructure
Self-Hosted Runner
NNO uses a single self-hosted GitHub Actions runner to avoid GitHub's free-tier build minute limits and to benefit from a persistent dependency cache.
| Property | Value |
|---|---|
| Name | nno-do-sgp1 |
| Labels | [self-hosted, nno-runner] |
| Provider | DigitalOcean Droplet |
| Region | SGP1 (Singapore) |
| Node.js | 20.18 (pinned via .nvmrc) |
| pnpm | 10.5.2 (pinned via packageManager in package.json) |
| pnpm store | Persistent across runs (fast installs via cache hits) |
| Concurrency | Single runner — jobs queue sequentially |
Why self-hosted? GitHub Actions free tier provides 2,000 minutes/month. The NNO monorepo has 7 Workers + 1 Pages app across 2 environments. A single deploy cycle takes ~10 minutes, exhausting free minutes within a week of active development. The DigitalOcean droplet costs ~$12/month and provides unlimited build minutes with faster installs (persistent pnpm store).
GitHub-Hosted Runner
Low-criticality workflows that don't need infrastructure access use ubuntu-latest:
- Publish Packages — Changesets versioning + GitHub Packages
- Claude Code — AI-assisted PR reviews and issue responses
2. Workflows
Overview
| Workflow | File | Trigger | Runner | Timeout | Purpose |
|---|---|---|---|---|---|
| CI | ci.yml | PR, merge_group | self-hosted | 15m | Quality gates: lint, typecheck, format, build, test |
| Deploy (Pages) | deploy.yml | Push to main/develop | self-hosted | 15m | Build + deploy console app to CF Pages |
| Deploy Workers | deploy-workers.yml | Push to main/develop | self-hosted | 20m | Deploy 7 Workers + D1 migrations in dependency order |
| Build Auth Bundle | build-auth-bundle.yml | Push to main (services/auth/src/**), manual | self-hosted | 10m | ESBuild auth template → R2 upload |
| Publish | publish.yml | Push to main, release, manual | ubuntu-latest | - | Changesets version bump / publish to GitHub Packages |
| Claude Code | claude.yml | Issue comment, PR review | ubuntu-latest | - | AI assistance on @claude mention |
| Claude Code Review | claude-code-review.yml | PR opened/synced | ubuntu-latest | - | Automatic PR review |
Concurrency Strategy
All workflows use concurrency groups to prevent duplicate runs:
| Workflow | Concurrency Group | Cancel In-Progress |
|---|---|---|
| CI | ci-\{ref\} | Yes (non-main only) |
| Deploy | deploy-\{ref\} | Yes (non-main only) |
| Deploy Workers | deploy-workers-\{ref\} | Yes (non-main only) |
| Build Auth Bundle | auth-bundle-\{ref\} | Never (R2 upload safety) |
| Publish | publish-\{ref\} | Never (versioning safety) |
Single runner bottleneck: Since all self-hosted workflows share one runner, jobs queue when the runner is busy. Production deploys (
main) are never cancelled. Staging deploys (develop) can be cancelled by newer pushes to reduce queue buildup.
3. Deploy Workers Pipeline
The most complex workflow. Deploys all 7 Cloudflare Workers in strict dependency order.
Service Deployment Sequence
IAM ──> Billing ──> Registry ──> Provisioning ──> CLI Service ──> Stack Registry ──> Gateway
│ │ │ │
│ │ │ ├── Ensure CF Queues exist
│ │ │ └── D1 migrations
│ │ └── D1 migrations (depends on IAM + Billing bindings)
│ └── D1 migrations
└── D1 migrationsWhy this order?
- IAM first — all services depend on IAM for auth
- Billing before Registry — Registry has
NNO_BILLINGservice binding. CF API rejects deploys referencing Workers that don't exist yet - Provisioning after Registry — needs CF Queues pre-created before deploy
- Gateway last — proxies all other services; must deploy after its upstream targets exist
Environment Resolution
The workflow resolves the target environment from the branch:
# Branch → Environment mapping
main → is_prod=true, env_flag="" # wrangler deploy (no --env)
develop → is_prod=false, env_flag="--env stg" # wrangler deploy --env stgEach service's wrangler.toml defines [env.stg] with:
- Worker name with
-stgsuffix (e.g.,nno-k3m9p2xw7q-gateway-stg) - Service bindings pointing to
-stgvariants - Separate D1 database IDs, KV namespace IDs
ENVIRONMENT = "stg"var
Queue Provisioning
The Provisioning service requires CF Queues to exist before deploy. A pre-deploy step ensures they're created:
- name: Ensure CF Queues exist (Provisioning)
run: |
wrangler queues create "$QUEUE" 2>/dev/null || echo "Already exists"
wrangler queues create "$DLQ" 2>/dev/null || echo "Already exists"| Environment | Queue | DLQ |
|---|---|---|
| Production | nno-k3m9p2xw7q-provision-queue | nno-k3m9p2xw7q-provision-dlq |
| Staging | nno-k3m9p2xw7q-provision-queue-stg | nno-k3m9p2xw7q-provision-dlq-stg |
Queue names follow the Cloudflare naming convention: production = no suffix, staging = -stg.
4. Deploy Pages Pipeline
Builds and deploys the NNO Console app (React 19 + Vite) to Cloudflare Pages.
Environment Isolation
Each environment uses a separate CF Pages project (see dns-naming.md):
| Branch | Build Command | Pages Project | Custom Domain |
|---|---|---|---|
develop | build:stg (.env.stg) | nno-k3m9p2xw7q-console-stg | console.app.stg.nno.app |
main | build:prod (.env.prod) | nno-k3m9p2xw7q-console | console.app.nno.app, console.nno.app |
Why separate projects? CF Pages custom domains always route to the production deployment. A single project with preview branches cannot serve staging on a custom domain. See dns-naming.md for the Phase 3 routing worker alternative.
Build Pipeline
checkout → pnpm install → turbo build (packages) → vite build --mode {stg|prod} → wrangler pages deployVite embeds VITE_* environment variables at build time. The .env.stg file points all service URLs to *.svc.stg.nno.app, while .env.prod points to *.svc.nno.app.
5. Auth Bundle Pipeline
Builds the auth Worker template (services/auth/) into a self-contained ESM bundle for per-platform deployment by the Provisioning service.
Trigger
- Push to
mainwhenservices/auth/src/**files change - Manual dispatch (
workflow_dispatch)
Steps
checkout → pnpm install → esbuild (ESM, minified, ES2022) → R2 upload (versioned + latest) → update NNO_AUTH_BUNDLE_URL secretR2 Storage
| Path | Purpose |
|---|---|
auth/v\{SHORT_SHA\}.js | Versioned bundle (immutable) |
auth/latest.js | Latest alias (overwritten on each build) |
The NNO_AUTH_BUNDLE_URL secret is updated on both prod and stg Provisioning Workers to point to the latest bundle URL.
Prerequisites: R2 bucket
nno-k3m9p2xw7q-auth-bundlesmust exist. Custom domainauth-bundles.nno.appmust be configured on the bucket.
6. CI Pipeline
Runs on pull requests and merge groups. Does not run on main/develop push (Deploy Workers handles validation for those branches).
Quality Gates
steps:
- pnpm install --frozen-lockfile
- pnpm turbo build # Build all packages
- pnpm lint # ESLint across workspace
- pnpm typecheck # tsc --noEmit across workspace
- pnpm format:check # Prettier check
- pnpm test # Vitest (filtered to: core, sdk, ui-auth, billing, iam)Test Filtering
Tests run only for packages with stable test suites:
"test": "turbo test --filter=@neutrino-io/{core,sdk,ui-auth,service-billing,service-iam}"This avoids CI failures from packages with incomplete or flaky test coverage.
7. Package Publishing
Manages versioning and publishing of @neutrino-io/* packages to GitHub Packages.
| Tool | Purpose |
|---|---|
| Changesets | Semantic versioning based on change descriptions |
| GitHub Packages | npm registry at https://npm.pkg.github.com |
Flow
- Developer adds a changeset:
pnpm changeset - On merge to main,
changesets/actioncreates a version bump PR - When the version PR is merged, packages are published to GitHub Packages
8. Secrets
| Secret | Used By | Purpose |
|---|---|---|
CF_API_TOKEN | Deploy, Deploy Workers, Build Auth Bundle | Cloudflare API (Workers, R2, D1, Queues, Pages) |
CF_ACCOUNT_ID | Deploy, Deploy Workers, Build Auth Bundle | Cloudflare account identifier |
NPM_TOKEN | CI, Deploy, Publish | GitHub Packages authentication |
CLAUDE_CODE_OAUTH_TOKEN | Claude workflows | Claude Code AI action |
TURBO_TEAM / TURBO_TOKEN | CI, Deploy Workers | Turborepo remote caching |
Service-Level Secrets (set via wrangler secret put)
| Secret | Services | Purpose |
|---|---|---|
AUTH_SECRET | IAM | Better Auth signing key |
AUTH_API_KEY | All services | Service-to-service static API key |
NNO_INTERNAL_API_KEY | Registry, Gateway | Internal auth for operator endpoints |
STRIPE_SECRET_KEY | Billing | Stripe API access |
STRIPE_WEBHOOK_SECRET | Billing | Stripe webhook signature verification |
NNO_AUTH_BUNDLE_URL | Provisioning | URL to pre-built auth Worker JS bundle |
GITHUB_APP_PRIVATE_KEY | CLI Service | GitHub App authentication for repo management |
9. Build System (Turborepo)
The monorepo uses Turborepo for task orchestration with dependency-aware caching.
Task Graph
┌──────────┐
│ build │ ← depends on ^build (upstream packages)
└────┬─────┘
│
┌─────────┼──────────┐
│ │ │
┌───▼──┐ ┌───▼────┐ ┌───▼───┐
│ lint │ │typecheck│ │ test │ ← all depend on ^build
└──────┘ └────────┘ └───────┘Configuration (turbo.json)
{
"globalEnv": ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID"],
"globalDependencies": [".env", "apps/console/.env.stg", "apps/console/.env.prod"],
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"lint": { "dependsOn": ["^build"] },
"typecheck": { "dependsOn": ["^build"] },
"test": { "dependsOn": ["^build"] },
"dev": { "persistent": true, "cache": false }
},
"concurrency": "50%"
}10. Scaling Considerations
Current Limitations
| Limitation | Impact | Mitigation |
|---|---|---|
| Single self-hosted runner | Jobs queue sequentially; ~10-15 min wait during active development | Concurrency groups cancel stale staging deploys |
| Single CF account | All services share account-level quotas | Naming convention provides logical isolation |
| Sequential Worker deploys | Full deploy takes ~8-10 minutes | Service binding order is mandatory; cannot parallelize |
Future Improvements (Phase 3)
| Improvement | Benefit |
|---|---|
| Staging environment routing Worker | Eliminate separate -stg Pages projects; proxy *.stg.nno.app to branch preview URLs |
| Second self-hosted runner | Parallel staging + production deploys; reduce queue wait times |
| Cloudflare Workers Builds | Replace GitHub Actions entirely; deploy Workers from within the CF ecosystem |
| CF Pages Direct Upload | Eliminate per-client GitHub repos for platform builds |