Skip to content

Monorepo Setup Guide

Step-by-step guide to building a production-grade Node.js monorepo for TypeScript packages with full automation.


Table of Contents

  1. Prerequisites
  2. Initialize the repository
  3. pnpm workspaces
  4. TypeScript
  5. Biome — linting & formatting
  6. Git hooks — Husky + commitlint
  7. Package build — tsdown
  8. Testing — Vitest
  9. Compatibility checks — publint + attw
  10. API documentation — TypeDoc
  11. Documentation site — VitePress
  12. Release automation — semantic-release
  13. CI/CD — GitHub workflows
  14. Adding a new package

1. Prerequisites

Before starting, ensure the following tools are available on your machine:

  • Node.js ≥ 22 (LTS recommended)
  • pnpm ≥ 10 — install via Corepack: corepack enable && corepack prepare pnpm@latest --activate
  • Git ≥ 2.x

Why Corepack? Corepack ships with Node.js and manages package manager versions without requiring a global install. It also enforces the packageManager field in package.json, which is how we prevent contributors from accidentally using npm or yarn.


2. Initialize the repository

bash
git init
git branch -M main
git checkout -b develop

Branching strategy

This repo uses a two-branch model:

BranchPurpose
mainStable, releasable state. Protected. Never commit directly.
developActive development. All day-to-day work goes here.

The release flow is:

  1. Work accumulates on develop via feature branches or direct commits.
  2. When a release is ready, open a PR from developmain.
  3. Merging that PR triggers semantic-release on main (see step 12), which bumps versions, generates CHANGELOGs, and publishes packages automatically.

This model keeps main always in a releasable state and gives a clear integration point for release decisions, without the overhead of a full Gitflow (no release/* branches needed — semantic-release handles versioning).

Create the root package.json:

json
{
  "name": "monorepo",
  "private": true,
  "type": "module",
  "packageManager": "pnpm@10.33.0",
  "engines": {
    "node": ">=22",
    "pnpm": ">=10"
  },
  "scripts": {
    "build": "pnpm -r build",
    "test": "pnpm -r test",
    "test:coverage": "pnpm -r test:coverage",
    "lint": "biome check .",
    "format": "biome format --write .",
    "docs:api": "typedoc",
    "docs:dev": "vitepress dev docs",
    "docs:build": "vitepress build docs",
    "docs:preview": "pnpm docs:api && pnpm docs:dev",
    "prepare": "husky"
  }
}

Create .npmrc at the root to enforce strict engine checks and prevent accidental npm usage:

ini
engine-strict=true
public-hoist-pattern[]=@myorg/*

engine-strict=true causes pnpm (and npm) to hard-fail if the running Node/pnpm version does not satisfy engines. Combined with packageManager and Corepack, this is the strongest available guardrail against wrong-toolchain installs.

public-hoist-pattern tells pnpm to hoist matching workspace packages into the root node_modules, making them resolvable from the repo root. This is required for TypeDoc's packages strategy: TypeDoc runs from the root and needs to resolve inter-package dependencies (e.g. @myorg/core) when generating docs. Without this, TypeDoc fails with "cannot find module" errors. Replace @myorg/* with your own package scope.

Create a .gitignore:

txt
node_modules/
dist/
.cache/
*.tsbuildinfo
coverage/
**/.vitepress/dist/
**/.vitepress/cache/
docs/api

docs/api is generated by TypeDoc (step 10) and must not be committed. The CI docs workflow regenerates it on every deploy.


3. pnpm workspaces

Create pnpm-workspace.yaml at the root:

yaml
packages:
  - "packages/*"

All packages live under packages/. Each sub-directory is an independent publishable package.

Why a flat packages/* layout vs nested scopes? A flat layout keeps pnpm -r filters simple and avoids glob ambiguity in workspace tooling. If you need logical grouping, use package name scopes (e.g. @myorg/ui, @myorg/utils) rather than nested directories.

Create the packages/ directory and a .gitkeep to track it:

bash
mkdir packages && touch packages/.gitkeep

Verifying workspace linking

When a package A depends on package B in the same monorepo, declare it as a workspace dependency:

json
{
  "dependencies": {
    "@myorg/utils": "workspace:*"
  }
}

pnpm install will symlink B into A's node_modules automatically. The workspace:* protocol resolves to the exact local version at publish time.


4. TypeScript

Install TypeScript and Node.js type definitions at the root (used by all packages):

bash
pnpm add -Dw typescript @types/node

Create a root tsconfig.base.json that all packages extend:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "lib": ["ES2022"],
    "types": ["node"],
    "composite": true
  }
}

Create a root tsconfig.json that references all packages (for editor support and project-wide type checking):

json
{
  "files": [],
  "references": [
    { "path": "./packages/your-package" }
  ]
}

@types/node is required because tsconfig.base.json declares "types": ["node"]. Without it, TypeScript cannot find the Node.js type definitions, causing TS2688 errors and unresolved globals like console. Tools that invoke tsc directly — such as TypeDoc — will fail even if your bundler (tsdown) works fine on its own.

verbatimModuleSyntax enforces that import type is used for type-only imports. This is essential for bundlers (like tsdown) that strip types without running tsc — it prevents runtime errors from type-only imports being emitted as real require() calls.

module: "NodeNext" with moduleResolution: "NodeNext" is the correct setting for packages targeting Node.js. It requires explicit .js extensions on relative imports (e.g. import { foo } from './foo.js') even in TypeScript source files, which aligns with ESM semantics.


5. Biome — linting & formatting

Biome replaces both ESLint and Prettier in a single, fast Rust-based tool.

bash
pnpm add -Dw @biomejs/biome @side-xp/biome-config

biome.json at the root extends the shared config:

json
{
  "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
  "extends": ["@side-xp/biome-config/biome.json"]
}

The shared config (@side-xp/biome-config) enforces:

  • 2-space indentation, 120-character line width, LF line endings
  • Single quotes, no semicolons, trailing commas everywhere
  • recommended lint rules
  • Import organization via Biome's assist feature (Biome 2.x)

Override any rule locally by adding it to your biome.json alongside the extends — local keys merge on top of the extended config.

Add to package.json scripts (already listed in step 2):

bash
pnpm lint     # check
pnpm format   # auto-fix formatting

Why Biome over ESLint + Prettier? The two-tool combo has well-known friction: format-on-save fights with lint-on-save, plugin version conflicts, and slow cold starts on large repos. Biome is 10–100× faster and has a single config file. The tradeoff is a smaller plugin ecosystem — but for a greenfield repo, the recommended ruleset covers the vast majority of real issues.

Why a shared config package? Centralizing Biome rules in @side-xp/biome-config means all projects in the org stay consistent without copy-pasting config. When rules need to change, a single package update propagates everywhere.


6. Git hooks — Husky + commitlint

Husky

bash
pnpm add -Dw husky
pnpm exec husky init

This creates .husky/ with a sample pre-commit hook.

Lint-staged (pre-commit)

Run Biome only on staged files to keep pre-commit fast:

bash
pnpm add -Dw lint-staged

.husky/pre-commit:

sh
pnpm exec lint-staged

.lintstagedrc.json:

json
{
  "**/*.{ts,tsx,js,jsx,json,jsonc}": ["biome check --write"]
}

commitlint (pre-push or commit-msg)

bash
pnpm add -Dw @commitlint/cli @commitlint/config-conventional

.commitlintrc.json:

json
{ "extends": ["@commitlint/config-conventional"] }

.husky/commit-msg:

sh
pnpm exec commitlint --edit "$1"

Conventional commits format: type(scope): description — e.g. feat(utils): add debounce helper. Types: feat, fix, docs, chore, refactor, perf, test, ci, build, revert. This format is the input contract for semantic-release's automatic versioning in step 12.

Why commitlint on commit-msg rather than pre-push? Catching a bad commit message immediately (before it's in history) is far less disruptive than rejecting a push with multiple already-made commits. Fix it once, not five times.

Git GUI clients (Fork, Sourcetree, Tower, etc.)

Git GUIs don't inherit your shell's PATH, so pnpm and node are not found at runtime.

Husky v9 provides ~/.config/husky/init.sh — sourced by the runner before every hook — to fix this. Create it once per machine:

sh
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

This is a one-time global setup per machine, not per project. Some GUIs (eg. Fork) also expose a dedicated "Git environment PATH" setting that can be used as an alternative.


7. Package build — tsdown

tsdown is a zero-config TypeScript package bundler built on Rolldown (the Rust-based Rollup successor). It produces both CJS and ESM outputs with proper .d.ts declaration files.

bash
pnpm add -Dw tsdown

Each package gets its own tsdown.config.ts:

ts
import { defineConfig } from 'tsdown'

export default defineConfig([
  {
    entry: ['src/index.ts'],
    format: ['esm'],
    dts: true,
    clean: true,
    sourcemap: true,
    outExtensions: () => ({ js: '.js', dts: '.d.ts' }),
  },
  {
    entry: ['src/index.ts'],
    format: ['cjs'],
    dts: true,
    outExtensions: () => ({ js: '.cjs', dts: '.d.cts' }),
  },
])

Why split into two config entries? When building dual ESM+CJS in a single pass, tsdown defaults to .mjs/.cjs extensions to disambiguate. Splitting the configs and using outExtensions gives conventional .js for ESM (correct when "type": "module" is set) and .cjs for CommonJS, which matches the exports field consumers expect.

Each package's package.json should expose the built output correctly:

json
{
  "name": "@myorg/my-package",
  "version": "0.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsdown",
    "dev": "tsdown --watch"
  }
}

Why dual CJS+ESM output? The Node.js ecosystem is still in transition. Many tools (Jest, older bundlers, some CLIs) require CJS. Publishing both formats ensures maximum compatibility without forcing consumers to change their setup.

tsdown vs tsup: tsup (esbuild-based) is more battle-tested. tsdown is newer and Rolldown is still pre-1.0, so expect occasional rough edges. The upside is that tsdown will track Vite's bundler evolution and is likely the long-term standard. Either is a valid choice — just be aware of the maturity gap.


8. Testing — Vitest

bash
pnpm add -Dw vitest @vitest/coverage-v8

Root vitest.workspace.ts (workspace mode):

ts
export default ['packages/*/vitest.config.ts']

Vitest 4.x removed defineWorkspace from vitest/config. The workspace is now declared in a dedicated vitest.workspace.ts file with a plain array export — no import needed.

Each package's vitest.config.ts:

ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: false,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json-summary'],
      include: ['src/**'],
      exclude: ['src/**/*.test.ts'],
    },
  },
});

The test and test:coverage scripts are already wired up in the root package.json from step 2 (pnpm -r test / pnpm -r test:coverage). Each package exposes its own test and test:coverage scripts that Vitest runs.

json-summary reporter is required for davelosert/vitest-coverage-report-action (see step 13) to post a coverage summary comment on PRs. text is kept for readable local output. No third-party service needed — the action uses GITHUB_TOKEN directly.

Why not Jest? Vitest shares Vite's plugin ecosystem, has first-class TypeScript support without a separate transform step, and is significantly faster due to parallel execution and esbuild-based transforms. For a TypeScript-first monorepo, Vitest is the clear choice in 2025+.

globals: false is intentional. Relying on implicit globals (describe, it, expect) makes test files ambiguous outside the test runner. Explicit imports (import { describe, it, expect } from 'vitest') are better for IDE support and less surprising.


9. Compatibility checks — publint + attw

These two tools catch package distribution mistakes before publish.

bash
pnpm add -Dw publint @arethetypeswrong/cli

Add to each package's package.json:

json
{
  "scripts": {
    "check:publint": "publint",
    "check:attw": "attw --pack ."
  }
}

Run both in CI before publishing:

bash
pnpm -r check:publint
pnpm -r check:attw

What each tool checks:

  • publint — validates that exports, main, module, and types fields actually point to existing files, checks for common mistakes like missing CJS/ESM conditions.
  • attw (Are the Types Wrong?) — verifies that TypeScript consumers using different module resolution strategies (bundler, node16, nodenext) all get correct types. Catches issues that publint misses.

Running both is not redundant — they catch different classes of errors. Think of publint as runtime-distribution checks and attw as type-distribution checks.


10. API documentation — TypeDoc

TypeDoc generates API reference docs directly from TypeScript source and JSDoc comments.

bash
pnpm add -Dw typedoc typedoc-plugin-markdown typedoc-vitepress-theme

typedoc.json at the root:

json
{
  "$schema": "https://typedoc.org/schema.json",
  "plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"],
  "docsRoot": "./docs",
  "entryPointStrategy": "packages",
  "entryPoints": ["packages/*"],
  "out": "docs/api",
  "readme": "none",
  "githubPages": false,
  "entryFileName": "index",
  "hidePageHeader": true,
  "hideBreadcrumbs": true,
  "skipErrorChecking": true
}

Each package's package.json must declare its own TypeDoc entry point so TypeDoc's packages strategy can discover it:

json
{
  "typedocOptions": {
    "entryPoints": ["src/index.ts"],
    "tsconfig": "./tsconfig.json"
  }
}

entryPointStrategy: "packages" tells TypeDoc to discover each package's package.json and use its main/types entry. This is the correct mode for monorepos — avoid entryPointStrategy: "expand" which can produce confusing cross-package output.

typedoc-plugin-markdown outputs .md files instead of standalone HTML, which VitePress (step 11) can consume directly. This avoids maintaining two separate documentation systems.

typedoc-vitepress-theme is a companion plugin that generates a typedoc-sidebar.json file alongside the markdown output. VitePress imports this file to build the API sidebar automatically — no manual sidebar maintenance needed (see step 11).

docsRoot tells typedoc-vitepress-theme where the VitePress root is. It uses this path to compute correct sidebar link prefixes. Set it to the directory containing your .vitepress/ folder.


11. Documentation site — VitePress

VitePress hosts the project documentation and auto-generated API docs.

bash
pnpm add -Dw vitepress vite

Why install vite explicitly? VitePress bundles its own Vite internally, but Vitest 4.x requires a newer Vite than VitePress's internal version. Without an explicit root-level vite entry, pnpm may resolve Vitest's peer dependency against VitePress's older internal Vite, which breaks Vitest at startup. Adding vite at the root pins the version used by Vitest while VitePress continues to use its own copy.

Create the docs structure manually (skip vitepress init — it is interactive and adds scripts you already have):

Recommended structure:

docs/
  .vitepress/
    config.ts
  api/                        ← TypeDoc output (gitignored, generated)
  guide/
    index.md
  index.md                    ← homepage
packages/
  my-package/
    docs/
      index.md                ← package-level docs (committed, lives next to source)
    src/

Package-level documentation can live inside each package alongside its source code. VitePress serves them via srcDir and rewrites:

ts
// docs/.vitepress/config.ts
import { type DefaultTheme, defineConfig } from 'vitepress'
import typedocSidebar from '../api/typedoc-sidebar.json'

export default defineConfig({
  base: '/my-repo/',   // must match the GitHub repository name
  title: 'My Monorepo',
  description: 'Package documentation',

  // Serve markdown from the repo root so package docs live next to their code.
  srcDir: '..',
  srcExclude: [
    '**/node_modules/**',
    '**/dist/**',
    '**/coverage/**',
    '*.md',                   // exclude root-level README.md etc.
  ],

  // Map source paths (relative to srcDir) to clean URL paths.
  rewrites: {
    'docs/:path(.*)': ':path',
    'packages/my-package/docs/:page': 'my-package/:page',
  },

  themeConfig: {
    nav: [
      { text: 'Guide', link: '/guide/' },
      { text: 'Packages', items: [
        { text: 'my-package', link: '/my-package/' },
      ]},
      { text: 'API Reference', link: '/api/' },
    ],
    sidebar: {
      '/guide/': [{ text: 'Introduction', link: '/guide/' }],
      '/my-package/': [{ text: '@myorg/my-package', items: [
        { text: 'Overview', link: '/my-package/' },
      ]}],
      '/api/': typedocSidebar,   // auto-generated by typedoc-vitepress-theme
    },
  },
})

srcDir: '..' sets the VitePress source root to the repo root instead of docs/. This lets package docs live inside packages/*/docs/ and be served by VitePress without copying files. The rewrites map those paths to clean URLs.

typedocSidebar is imported directly from the file generated by typedoc-vitepress-theme. The sidebar stays in sync with the API automatically — add or remove an export, rerun pnpm docs:api, and the sidebar updates with no config changes needed.

Generate API docs before building or previewing the site: pnpm docs:api && pnpm docs:build.


12. Release automation — semantic-release

semantic-release automates version bumps and CHANGELOG generation based on conventional commit messages.

bash
pnpm add -Dw semantic-release multi-semantic-release \
  @semantic-release/changelog \
  @semantic-release/exec \
  @semantic-release/git \
  @semantic-release/github

multi-semantic-release runs from the repo root and handles all packages in topological order — packages are released after their local dependencies. This matters when pkg-b depends on pkg-a: if both changed, pkg-a must be published first so pkg-b can resolve the new version. The alternative (semantic-release-monorepo) runs per-package and doesn't handle this ordering.

@semantic-release/npm is bundled with semantic-release itself — no separate install needed. Same for @semantic-release/commit-analyzer and @semantic-release/release-notes-generator.

Root .releaserc.json:

json
{
  "branches": ["main"],
  "tagFormat": "${name}@${version}",
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    ["@semantic-release/changelog", { "changelogFile": "CHANGELOG.md" }],
    ["@semantic-release/npm", { "npmPublish": false }],
    ["@semantic-release/exec", { "prepareCmd": "pnpm install --no-frozen-lockfile" }],
    [
      "@semantic-release/git",
      {
        "assets": ["CHANGELOG.md", "package.json", "pnpm-lock.yaml"],
        "message": "chore(release): ${nextRelease.version} [skip ci]"
      }
    ],
    ["@semantic-release/exec", {
      "publishCmd": "pnpm publish --no-git-checks --access public"
    }],
    "@semantic-release/github"
  ]
}

tagFormat scopes release tags to the package name: @myorg/my-package@1.2.0 instead of a flat v1.2.0. This is essential in a monorepo where multiple packages are versioned independently — without it, all packages would share a single release tag namespace.

npmPublish: false disables @semantic-release/npm's publish step while keeping its version-bump logic. Publishing is delegated to @semantic-release/exec using pnpm publish --no-git-checks --access public.

Lockfile drift: multi-semantic-release rewrites workspace:* to the concrete resolved version (e.g. 1.0.0) in dependent packages' package.json as part of the release. This leaves the lockfile out of sync with package.json, causing pnpm install --frozen-lockfile to fail on the next CI run. The prepareCmd step regenerates the lockfile after the version bump (within the prepare lifecycle phase, before @semantic-release/git commits), and pnpm-lock.yaml is added to the git assets so it lands in the same release commit — no extra commit needed.

--deps.bump=ignore on multi-semantic-release prevents a dependent package from being released solely because one of its workspace dependencies was released. Without it, releasing pkg-a would trigger a patch bump of every package that depends on it, even with no new commits. This flag does not prevent the workspace:* → concrete version rewrite in package.json — that still happens and is handled by the lockfile regeneration above.

CHANGELOG.md, package.json, and pnpm-lock.yaml in @semantic-release/git assets are relative to each package root. multi-semantic-release runs semantic-release in the context of each package, so paths resolve correctly.

Each package that should be published to npm must set "private": false. Packages with "private": true are skipped by @semantic-release/npm automatically — useful for internal or not-yet-published packages.

Commit → version mapping:

Commit typeVersion bump
fix:patch (0.0.x)
feat:minor (0.x.0)
feat!: or BREAKING CHANGE:major (x.0.0)
chore:, docs:, test:no release

semantic-release vs changesets: changesets is an alternative that gives developers manual control over which packages get bumped (via changeset files checked into git). It's preferred when you want human curation over every release. semantic-release is fully automatic — better for teams that trust their commit discipline and want zero-friction releases. Given the conventional commit enforcement already in place (step 6), semantic-release is a natural fit here.


13. CI/CD — GitHub workflows

Each stage lives in its own reusable workflow file (prefixed with _). The orchestrator ci.yml wires them into a pipeline — readable at a glance, each stage independently maintainable.

.github/workflows/
  ci.yml        ← orchestrator
  _check.yml    ← lint, build, test, compat checks
  _release.yml  ← semantic-release
  _docs.yml     ← VitePress build + GitHub Pages deploy

ci.yml — the orchestrator:

yaml
name: CI

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

permissions:
  contents: write
  issues: write
  pull-requests: write
  packages: write
  pages: write
  id-token: write

jobs:
  check:
    uses: ./.github/workflows/_check.yml

  release:
    needs: check
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    uses: ./.github/workflows/_release.yml
    secrets: inherit

  docs:
    needs: release
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    uses: ./.github/workflows/_docs.yml
    secrets: inherit

_check.yml — runs on every trigger, posts coverage report on PRs:

yaml
name: Check

on:
  workflow_call:

jobs:
  check:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v6
      - uses: pnpm/action-setup@v5
      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm build
      - run: pnpm test:coverage
      - uses: davelosert/vitest-coverage-report-action@v2
        with:
          name: my-package
          working-directory: packages/my-package
      - run: pnpm -r check:publint
      - run: pnpm -r check:attw

_release.yml — only runs on push to main, after check passes:

yaml
name: Release

on:
  workflow_call:

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      issues: write
      pull-requests: write
      packages: write
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0
          persist-credentials: false
      - uses: pnpm/action-setup@v5
      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: pnpm
          registry-url: https://npm.pkg.github.com
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - run: pnpm exec multi-semantic-release --deps.bump=ignore
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

_docs.yml — only runs after release succeeds:

yaml
name: Deploy Docs

on:
  workflow_call:

jobs:
  docs:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pages: write
      id-token: write
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - uses: actions/checkout@v6
      - uses: pnpm/action-setup@v5
      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - run: pnpm docs:api
      - run: pnpm docs:build
      - uses: actions/configure-pages@v6
      - uses: actions/upload-pages-artifact@v5
        with:
          path: docs/.vitepress/dist
      - uses: actions/deploy-pages@v5
        id: deployment

Top-level permissions in the caller defines the maximum grant available to all called reusable workflows. GitHub doesn't allow setting permissions on individual jobs that call reusable workflows, so the ceiling must be set here. Each called workflow still declares its own narrower permissions — the union just has to fit within this ceiling.

needs + if on release and docs means they only run on push to main, and each only after the previous job succeeds. On PRs and develop pushes, only check runs.

secrets: inherit passes all caller secrets to the called workflow — required for GITHUB_TOKEN to be available inside _release.yml and _docs.yml.

fetch-depth: 0 is required on the release job — semantic-release walks commit history to determine what changed since the last release tag. A shallow clone (the default) will cause it to miss commits or fail entirely.

NODE_AUTH_TOKEN is set to GITHUB_TOKEN (not a separate secret) because actions/setup-node uses it to write the npm auth token into .npmrc for the configured registry-url. No extra secrets needed.

Publishing to npmjs.com instead: replace registry-url with https://registry.npmjs.org, set NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}, and remove "registry" from each package's publishConfig.


14. Adding a new package

This is the routine checklist for adding a package to the monorepo once the above is in place.

  1. Scaffold — create packages/my-package/ with:

    • package.json (see step 7 template) — include typedocOptions pointing to src/index.ts
    • tsconfig.json extending root base
    • tsdown.config.ts
    • vitest.config.ts
    • src/index.ts
  2. Register in root tsconfig.json — add { "path": "./packages/my-package" } to references.

  3. Install dependencies — run pnpm install from the root. Cross-package deps use workspace:*.

  4. Add package documentation — create packages/my-package/docs/index.md with installation and usage instructions.

  5. Register in VitePress — in docs/.vitepress/config.ts, add:

    • A rewrites entry: 'packages/my-package/docs/:page': 'my-package/:page'
    • A nav item and sidebar entry under /my-package/
  6. Add coverage report step — add a davelosert/vitest-coverage-report-action@v2 step to ci.yml with the new package's name and working-directory.

  7. Verify — run pnpm build && pnpm test && pnpm -r check:publint && pnpm -r check:attw && pnpm docs:api && pnpm docs:build.

  8. Publish config — add "publishConfig": { "registry": "https://npm.pkg.github.com", "access": "public" } to the package's package.json to publish to GitHub Packages. Omit "private": true (or remove it entirely).

The API reference sidebar updates automatically after step 6's docs:apitypedoc-vitepress-theme regenerates typedoc-sidebar.json from the new package's exports. Only the hand-written nav/sidebar entries in step 5 need manual attention.


Summary of tool choices

ConcernToolKey reason
Package managerpnpmBest-in-class workspace support, strict by default
LanguageTypeScriptRequired for type-safe packages
Lint + formatBiomeSingle tool, 10–100× faster than ESLint + Prettier
Git hooksHuskyWidely supported, integrates with lint-staged
Commit conventioncommitlintInput contract for semantic-release
BuildtsdownModern Rolldown-based bundler, zero config
TestsVitestTypeScript-native, fast, workspace-aware
Compat checkspublint + attwComplementary — catch runtime vs type dist errors
API docsTypeDocTypeScript-first, markdown output for VitePress
Docs siteVitePressVite-native, fast, integrates with TypeDoc output
Releasessemantic-releaseFully automated versioning from commit history
CIGitHub ActionsNative GitHub integration