CODEOWNERS-Driven Test Ownership in CI

A CODEOWNERS file already tells your version-control host who must review a change. This guide shows how to make that same file the single source of truth for running tests too — so a pull request that touches one team’s package runs that team’s suite, requires that team’s approval, and pages that team’s channel when the build goes red. The audience is tech leads and platform engineers running a GitHub Actions pipeline (the patterns port directly to GitLab CI) on a pnpm or npm monorepo, using Vitest 2 for unit and integration suites and Playwright 1.4x for end-to-end. It is the operational companion to the broader test ownership models guide: that page designs the boundaries; this one wires them into CI.

Root Cause Analysis

When CI runs the entire test suite on every pull request, ownership dissolves. A red check tells you that something broke but not whose code or which team should fix it, so triage becomes a manual archaeology dig through logs. The root cause is a missing mapping from changed files to responsible team at the moment CI dispatches work. CODEOWNERS encodes exactly that mapping for review, but most pipelines never read it — review routing and test routing live in two disconnected systems that drift apart the first time a directory moves.

The second failure vector is notification. Even teams that route review correctly often broadcast failures to one shared channel, so alerts become noise and the owning engineers learn their suite is broken from a teammate rather than from CI. The third is the silent-skip trap: when a changed path matches no rule, naive routing runs nothing and the build passes green on untested code. A correct system fails closed — an unmapped path is an error, not a skip — and reuses the one file humans already maintain so the review map and the execution map can never disagree. Getting this right depends on having clear coverage thresholds per owner, otherwise an owned suite can pass while asserting almost nothing.

Reproducible Setup

Assume a pnpm workspace with two product packages and one shared package. Install the runners and confirm the workspace layout.

pnpm add -D vitest@^2 @playwright/test@^1.47
repo/
├─ .github/
│  ├─ CODEOWNERS
│  └─ workflows/owned-tests.yml
├─ packages/
│  ├─ checkout/   (owned by checkout-squad)
│  ├─ auth/       (owned by auth-squad)
│  └─ shared/     (owned by platform-team)
├─ scripts/owners.ts
└─ vitest.config.ts

Define ownership once in CODEOWNERS. The order matters: the last matching pattern wins, so list shared and config paths after product paths.

# .github/CODEOWNERS
/packages/checkout/**   @acme/checkout-squad
/packages/auth/**       @acme/auth-squad
/packages/shared/**     @acme/platform-team
/vitest.config.ts       @acme/platform-team
/.github/**             @acme/platform-team

Give each owner an isolated Vitest project so a suite can be run by name, mirroring the structure in test ownership models.

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

export default defineConfig({
  test: {
    projects: [
      { test: { name: 'checkout-squad', include: ['packages/checkout/**/*.test.ts'] } },
      { test: { name: 'auth-squad', include: ['packages/auth/**/*.test.ts'] } },
      { test: { name: 'platform-team', include: ['packages/shared/**/*.test.ts'] } },
    ],
  },
});

Implementation

The core is a resolver that parses CODEOWNERS, matches it against the changed files in a pull request, and emits the set of owners to run. Parsing the real file — rather than a second hardcoded map — is what keeps review and execution in sync forever.

// scripts/owners.ts
import { readFileSync } from 'node:fs';
import { execSync } from 'node:child_process';

type Rule = { pattern: string; team: string };

function loadRules(path = '.github/CODEOWNERS'): Rule[] {
  return readFileSync(path, 'utf-8')
    .split('\n')
    .map((l) => l.trim())
    .filter((l) => l && !l.startsWith('#'))
    .map((l) => {
      const [pattern, team] = l.split(/\s+/);
      return { pattern, team };
    });
}

// Minimal CODEOWNERS glob: '/dir/**' matches any file under dir.
function matches(pattern: string, file: string): boolean {
  const base = pattern.replace(/^\//, '').replace(/\/\*\*$/, '');
  return file === base || file.startsWith(base.endsWith('/') ? base : `${base}/`);
}

export function resolveOwners(base = process.env.BASE_SHA ?? 'origin/main'): string[] {
  const rules = loadRules();
  const changed = execSync(`git diff --name-only ${base} HEAD`)
    .toString()
    .split('\n')
    .filter(Boolean);

  const owners = new Set<string>();
  for (const file of changed) {
    // Last matching rule wins, matching host CODEOWNERS semantics.
    const rule = [...rules].reverse().find((r) => matches(r.pattern, file));
    if (!rule) throw new Error(`No CODEOWNERS rule matches ${file}`); // fail closed
    owners.add(rule.team.replace('@acme/', ''));
  }
  return [...owners];
}

if (process.argv[1]?.endsWith('owners.ts')) {
  console.log(JSON.stringify(resolveOwners()));
}

Feed the resolver’s output into a GitHub Actions matrix so each owner runs as its own job, reports under its own name, and notifies its own channel.

# .github/workflows/owned-tests.yml
name: Owned tests
on: pull_request

jobs:
  resolve:
    runs-on: ubuntu-latest
    outputs:
      owners: ${{ steps.r.outputs.owners }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: corepack enable && pnpm install --frozen-lockfile
      - id: r
        env:
          BASE_SHA: origin/${{ github.base_ref }}
        run: echo "owners=$(pnpm -s tsx scripts/owners.ts)" >> "$GITHUB_OUTPUT"

  test:
    needs: resolve
    if: needs.resolve.outputs.owners != '[]'
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        owner: ${{ fromJson(needs.resolve.outputs.owners) }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: corepack enable && pnpm install --frozen-lockfile
      - name: Run ${{ matrix.owner }} suite
        run: pnpm vitest run --project "${{ matrix.owner }}" --reporter=junit --outputFile="results-${{ matrix.owner }}.xml"
      - name: Notify owner on failure
        if: failure()
        run: ./scripts/notify.sh "${{ matrix.owner }}" "Suite red on PR #${{ github.event.number }}"

Two wiring details make this enforceable rather than advisory. First, turn on Require review from Code Owners in branch protection so the host blocks merge until the owning team approves — the same CODEOWNERS file now governs both review and tests. Second, make each per-owner job a required status check named after the owner; a green pull request then provably means every affected owner’s suite passed, not that an unmapped change slipped through.

For end-to-end coverage, route Playwright projects the same way by passing the resolved owner as a --project filter, keeping browser suites on the same ownership axis as unit and integration suites described in unit vs integration vs e2e mapping.

Verification

Prove the routing works before trusting it. Open a pull request that touches only packages/checkout and confirm exactly one matrix job — checkout-squad — runs, that the checkout team is the required reviewer, and that an injected failure pages #checkout-ci and nothing else.

# Locally, simulate what CI resolves for the current branch:
BASE_SHA=origin/main pnpm tsx scripts/owners.ts
# Expect: ["checkout-squad"]

Add a guard test so a future change to CODEOWNERS cannot silently create an unmatched path. This asserts that the resolver fails closed, which is the property that keeps untested code from merging green.

// scripts/owners.test.ts
import { describe, it, expect, vi } from 'vitest';
import * as cp from 'node:child_process';
import { resolveOwners } from './owners';

describe('CODEOWNERS routing', () => {
  it('throws on a path with no owner', () => {
    vi.spyOn(cp, 'execSync').mockReturnValue('packages/orphan/a.ts\n' as never);
    expect(() => resolveOwners('HEAD~1')).toThrow(/No CODEOWNERS rule/);
  });
});

Finally, confirm enforcement at the host: attempt to merge without the owning team’s approval and verify the merge button is blocked. If it is not, the required-reviewer setting is off and the routing is advisory only.

Troubleshooting

Every PR runs every suite. The resolver is matching too broadly, or the matrix is hardcoded instead of reading needs.resolve.outputs.owners. Echo the resolver output in CI and confirm it shrinks for single-package changes.

A build passes with no tests run. A changed path matched no rule and the resolver did not fail closed. Confirm the throw branch is present and that CODEOWNERS has a catch-all last line (for example * @acme/platform-team) so nothing is truly unmapped.

The wrong team is paged. CODEOWNERS order is wrong — remember the last matching pattern wins. List specific product paths first and broad shared/config paths last, and re-run the resolver against a known file to confirm.

Required-reviewer gate never triggers. Branch protection must have both “Require a pull request before merging” and “Require review from Code Owners” enabled, and team handles in CODEOWNERS must be real, writable teams. An unresolvable handle is silently ignored by the host, dropping the gate.

Flaky owned suites block merges. Route persistent flakiness through a tracked quarantine rather than disabling the job; see flaky test mitigation for a policy that keeps accountability with the owning team.

FAQ

Does this work on GitLab CI instead of GitHub Actions?

Yes. GitLab supports a CODEOWNERS file and required approvals from code owners on protected branches, and the resolver script is host-agnostic because it parses the file and the git diff directly. Replace the Actions matrix with a rules: / parallel:matrix: block in .gitlab-ci.yml and feed it the same owner list; everything in the implementation section ports across with no logic changes.

What happens when a pull request touches files owned by several teams?

The resolver returns every matched owner, and the matrix runs one job per owner in parallel, so each affected team’s suite executes and each is added as a required reviewer by the host. The pull request cannot merge until all owning teams approve and all per-owner status checks are green, which is exactly the behaviour you want for a genuinely cross-cutting change.

How do I handle shared code that many teams depend on?

Assign shared packages to a horizontal platform team in CODEOWNERS and publish them with semantic versioning so consumers upgrade deliberately. A change to shared code then runs the platform team’s suite on the pull request, while a nightly all-owners run on the main branch catches any downstream package that the per-pull-request filter did not exercise.

Won’t parsing CODEOWNERS in a script drift from how the host interprets it?

It can if you reimplement the full glob grammar carelessly, so keep the script’s matching deliberately narrow — directory-prefix and exact-file rules — and add the guard test shown above. For most monorepos that subset covers every rule in the file; if you need full fidelity, run the host’s own code-owners API in the resolve job and consume its output instead of parsing locally.

How is this different from just requiring code-owner review?

Required review governs who approves a change; it does nothing about which tests run or who is notified when they fail. This pattern reuses the same file to drive all three, so review routing, test dispatch, and failure notification can never disagree — which is the core promise of a real ownership model rather than a documented intention.