Moonrepo TypeScript Monorepo Testing Setup: From Zero to CI

Moonrepo TypeScript Monorepo Testing Setup: From Zero to CI

This guide walks through the complete setup of a TypeScript monorepo using Moon.build from scratch — pnpm workspaces, shared tsconfig, Vitest per package, Moon task orchestration, and a GitHub Actions CI pipeline with remote caching. Estimated setup time: 45 minutes.

Key Takeaways

  1. Moon requires explicit project registration and task definitions, which makes the entire workspace self-documenting.
  2. TypeScript project references enforce correct build order and enable incremental compilation across packages.
  3. A shared tsconfig.base.json propagated through Moon's toolchain eliminates type-check inconsistencies.
  4. pnpm workspaces and Moon's project graph work independently — Moon reads package.json workspaces but adds its own dependency model on top.
  5. The first CI run is always slow; after that, Moon's cache makes subsequent runs 70-90% faster on average.

Prerequisites

Before starting, ensure you have:

  • Node.js 20 or later
  • pnpm 8 or later (npm install -g pnpm)
  • Git initialized in your project root
  • Moon CLI installed (curl -fsSL https://moonrepo.dev/install/moon.sh | bash)

Step 1: Initialize the pnpm Workspace

Create the project root and initialize pnpm:

mkdir my-monorepo && <span class="hljs-built_in">cd my-monorepo
git init
pnpm init

Create the pnpm workspace configuration:

# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'

Create the directory structure:

mkdir -p packages/utils packages/ui apps/web apps/api

Create a root .gitignore:

node_modules
dist
.moon/cache
.moon/out
coverage
test-results
*.log

Step 2: Initialize Moon

Run Moon's initialization command:

moon init

Answer the prompts:

  • VCS: git
  • Package manager: pnpm
  • Primary language: typescript

This creates .moon/workspace.yml. Update it:

# .moon/workspace.yml
vcs:
  manager: git
  defaultBranch: main
  remoteCandidates:
    - origin

projects:
  - 'packages/*'
  - 'apps/*'

runner:
  cacheLifetime: '7 days'
  inheritColorsForPipedTasks: true
  logRunningCommand: true

node:
  version: '20.11.0'
  packageManager: pnpm
  packageManagerVersion: '8.15.0'

Create the toolchain configuration:

# .moon/toolchain.yml
node:
  version: '20.11.0'
  packageManager: pnpm
  packageManagerVersion: '8.15.0'
  addEnginesConstraint: true
  dedupeOnLockfileChange: true

typescript:
  createMissingConfig: true
  rootConfigFileName: tsconfig.json
  rootOptionsConfigFileName: tsconfig.options.json
  syncProjectReferences: true
  syncProjectReferencesToPaths: true

The syncProjectReferences: true setting tells Moon to automatically maintain TypeScript project references as your dependency graph evolves — you won't need to manually update tsconfig.json files when adding a new dependsOn.

Step 3: Create Shared TypeScript Configuration

Create the root TypeScript options config:

// tsconfig.options.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "composite": true,
    "incremental": true,
    "tsBuildInfoFile": ".tsbuildinfo",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

Create the root project reference config:

// tsconfig.json
{
  "files": [],
  "references": []
}

Moon will populate the references array automatically as you add dependsOn to project moon.yml files (because of syncProjectReferences: true).

Step 4: Define Shared Task Defaults

Create the inherited task configuration:

# .moon/tasks.yml
tasks:
  build:
    command: tsc --build tsconfig.json
    inputs:
      - 'src/**/*'
      - 'tsconfig.json'
      - '/tsconfig.options.json'
    outputs:
      - 'dist'
      - '.tsbuildinfo'
    options:
      cache: true
      runInCI: true

  typecheck:
    command: tsc --noEmit
    inputs:
      - 'src/**/*'
      - 'tsconfig.json'
      - '/tsconfig.options.json'
    options:
      cache: true
      runInCI: true

  test:
    command: vitest run
    inputs:
      - 'src/**/*'
      - 'tests/**/*'
      - 'vitest.config.ts'
      - 'vitest.config.mts'
      - 'package.json'
      - '/vitest.shared.ts'
    outputs:
      - 'coverage'
      - 'test-results'
    deps:
      - 'typecheck'
    options:
      cache: true
      runInCI: true
      outputStyle: stream

  lint:
    command: eslint src --ext .ts,.tsx --max-warnings 0
    inputs:
      - 'src/**/*'
      - '.eslintrc.*'
      - '/.eslintrc.*'
    options:
      cache: true
      runInCI: true

  test:watch:
    command: vitest watch
    local: true
    inputs:
      - 'src/**/*'
      - 'tests/**/*'
    options:
      cache: false
      persistent: true

Step 5: Create the utils Package

mkdir -p packages/utils/src packages/utils/tests
// packages/utils/package.json
{
  "name": "@myorg/utils",
  "version": "0.0.1",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsc --build",
    "test": "vitest run"
  }
}
// packages/utils/tsconfig.json
{
  "extends": "../../tsconfig.options.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src"],
  "references": []
}
# packages/utils/moon.yml
language: typescript
type: library

# Inherits all tasks from .moon/tasks.yml

Create source and test files:

// packages/utils/src/format.ts
export function formatDate(date: Date, locale = 'en-US'): string {
  return new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(date)
}

export function formatCurrency(
  amount: number,
  currency = 'USD',
  locale = 'en-US'
): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
  }).format(amount)
}
// packages/utils/src/index.ts
export * from './format.js'
// packages/utils/tests/format.test.ts
import { describe, it, expect } from 'vitest'
import { formatDate, formatCurrency } from '../src/format.js'

describe('formatDate', () => {
  it('formats a date in the default locale', () => {
    const date = new Date('2024-01-15')
    const result = formatDate(date, 'en-US')
    expect(result).toBe('January 15, 2024')
  })

  it('formats a date in a different locale', () => {
    const date = new Date('2024-01-15')
    const result = formatDate(date, 'de-DE')
    expect(result).toContain('15')
    expect(result).toContain('2024')
  })
})

describe('formatCurrency', () => {
  it('formats USD by default', () => {
    const result = formatCurrency(1234.56)
    expect(result).toBe('$1,234.56')
  })

  it('formats EUR', () => {
    const result = formatCurrency(1234.56, 'EUR', 'de-DE')
    expect(result).toContain('1.234,56')
    expect(result).toContain('€')
  })
})
// packages/utils/vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    name: 'utils',
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json'],
      reportsDirectory: 'coverage',
    },
    reporters: [
      'default',
      ['junit', { outputFile: 'test-results/junit.xml' }],
    ],
  },
})

Step 6: Create the api-client Package (Depends on utils)

mkdir -p packages/api-client/src packages/api-client/tests
// packages/api-client/package.json
{
  "name": "@myorg/api-client",
  "version": "0.0.1",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "dependencies": {
    "@myorg/utils": "workspace:*"
  }
}
# packages/api-client/moon.yml
language: typescript
type: library

dependsOn:
  - utils     # Moon will add tsconfig reference automatically

# Inherits all tasks from .moon/tasks.yml
// packages/api-client/src/client.ts
import { formatCurrency } from '@myorg/utils'

export interface Product {
  id: string
  name: string
  priceInCents: number
  currency: string
}

export interface FormattedProduct extends Product {
  formattedPrice: string
}

export function formatProduct(product: Product): FormattedProduct {
  return {
    ...product,
    formattedPrice: formatCurrency(product.priceInCents / 100, product.currency),
  }
}
// packages/api-client/tests/client.test.ts
import { describe, it, expect } from 'vitest'
import { formatProduct } from '../src/client.js'

describe('formatProduct', () => {
  it('formats the product price', () => {
    const result = formatProduct({
      id: '1',
      name: 'Test Product',
      priceInCents: 999,
      currency: 'USD',
    })
    expect(result.formattedPrice).toBe('$9.99')
  })
})

Step 7: Install Dependencies

# Root tooling
pnpm add -D -w vitest @vitest/coverage-v8 typescript eslint

<span class="hljs-comment"># Install package dependencies
pnpm install

Step 8: Set Up GitHub Actions CI

# .github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  ci:
    name: Lint, Typecheck, Test
    runs-on: ubuntu-latest
    env:
      MOONBASE_SECRET_KEY: ${{ secrets.MOONBASE_SECRET_KEY }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0   # required for Moon's affected detection

      - name: Setup Moon
        uses: moonrepo/setup-moon-action@v1
        with:
          auto-install: true

      - name: Setup pnpm
        uses: pnpm/action-setup@v3
        with:
          version: 8

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Run affected tasks
        run: |
          moon run :lint :typecheck :test \
            --affected \
            --base ${{ github.base_ref || 'main' }} \
            --head ${{ github.sha }} \
            --concurrency 4

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: '**/test-results/junit.xml'
          if-no-files-found: ignore

      - name: Upload coverage
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: '**/coverage/'
          if-no-files-found: ignore

Step 9: Verify the Setup

Run your first Moon task:

# Run tests for all packages
moon run :<span class="hljs-built_in">test

<span class="hljs-comment"># Verify caching works — second run should be instant
moon run :<span class="hljs-built_in">test

<span class="hljs-comment"># Run only affected packages (simulates CI behavior)
moon run :<span class="hljs-built_in">test --affected --base main

Expected output on the second run:

▸ utils:typecheck (cache hit, skipped in 3ms)
▸ utils:test (cache hit, skipped in 2ms)
▸ api-client:typecheck (cache hit, skipped in 2ms)
▸ api-client:test (cache hit, skipped in 3ms)

Tasks: 4 cached, 0 failed, 0 passed
  Time: 127ms

Step 10: Add a New Package (Testing the Workflow)

The real test of your setup is how it handles growth. Add a new package:

mkdir -p packages/validators/src packages/validators/tests

Create packages/validators/moon.yml:

language: typescript
type: library

dependsOn:
  - utils

Create packages/validators/package.json, tsconfig.json, and vitest.config.ts following the same pattern as utils. Moon will:

  1. Detect the new project from the packages/* glob in workspace.yml
  2. Automatically add a TypeScript project reference to packages/utils/tsconfig.json
  3. Include validators in the dependency graph for affected detection

Run moon sync to apply the TypeScript reference sync:

moon sync projects

Common Issues and Fixes

Error: "Project not found" Run moon query projects to see what Moon has detected. If your new package is missing, check that its directory is covered by the glob in workspace.yml and that it has a package.json.

TypeScript errors in CI but not locally Check that your tsconfig.options.json target and module settings match the Node.js version in toolchain.yml. A mismatch causes different module resolution behavior.

Cache miss on every CI run The most common cause is a file in your inputs glob that changes between runs — a lockfile, a generated file, or a timestamp. Run moon query hash <project>:<task> on your local machine and in CI and compare the hashes. The differing input will be visible in the hash computation output.

Tests pass locally but fail in CI Check whether your tests depend on environment variables or file paths that differ between environments. Add any environment-specific inputs to the env section of your task definition so they're included in the cache key.

Adding Browser Test Coverage

Your Moon CI pipeline now handles linting, type-checking, and unit tests. For browser-level end-to-end coverage of your deployed applications, HelpMeTest integrates into the same CI workflow. Write tests in plain English — describe the user action and the expected result — and HelpMeTest runs them against your deployed app. No Playwright configuration, no test runner setup. It's the natural complement to a Moon + Vitest unit test stack when you need to verify the full user experience.

Summary

Setting up a TypeScript monorepo with Moon takes roughly 45 minutes and pays dividends on every subsequent PR. The key pieces are: pnpm workspaces for package management, Moon's syncProjectReferences for automatic TypeScript configuration maintenance, shared task defaults in .moon/tasks.yml to keep per-package config minimal, and --affected detection in GitHub Actions to run only what changed. Once the first slow CI run warms the cache, your team will see dramatically faster feedback on every pull request.

Read more