en

CI integration

Use pcpm ci for strict, reproducible installs on CI.

CI is where PCPM pays for itself. A clean-room build of a large .NET solution that used to take minutes becomes a few seconds, because the global store is already warm on most CI runners, and pcpm ci is strict enough to catch “works on my machine” drift.

The shape of a PCPM CI run

  1. Restore: pcpm ci — installs exactly what pcpm.lock says, no floating, no surprises, fails fast if the lockfile is stale.
  2. Build: dotnet build — unchanged from a non-PCPM pipeline.
  3. Test: dotnet test — unchanged.

You can do steps 2 and 3 with a single dotnet test invocation; PCPM only owns step 1.

Why pcpm ci and not pcpm install?

pcpm install is a developer’s command. It does its best to keep going even if the lockfile is out of date — for example, if you pcpm add Foo and forget to commit pcpm.lock, pcpm install will resolve fresh and update the lockfile.

pcpm ci is a CI command. It:

  • Refuses to run if pcpm.lock is missing.
  • Refuses to run if Directory.Packages.props and pcpm.lock disagree.
  • Never writes to pcpm.lock. It either succeeds with the exact versions in the lockfile, or it fails with a non-zero exit.

This catches a class of bugs that are otherwise silent: someone bumps a version in CPM and forgets to update the lockfile. On a developer machine, pcpm install would re-resolve and the new version would slip in. On CI, pcpm ci would fail and the build would be red.

GitHub Actions

name: build

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x

      - name: Cache PCPM store
        uses: actions/cache@v4
        with:
          path: |
            ~/.local/share/pcpm/store
            ~/.nuget/packages
          key: pcpm-${{ hashFiles('**/pcpm.lock') }}
          restore-keys: |
            pcpm-

      - name: Install PCPM
        run: dotnet tool install --global pcpm

      - name: Restore
        run: pcpm ci

      - name: Build
        run: dotnet build --no-restore -c Release

      - name: Test
        run: dotnet test --no-build -c Release

The cache step is the part that makes this fast. The cache key is pcpm.lock, so the store is reused as long as the lockfile is the same. A typical warm restore takes 2–5 seconds on a 50-project solution.

GitLab CI

build:
  image: mcr.microsoft.com/dotnet/sdk:10.0
  variables:
    PCPM_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pcpm"
  cache:
    key:
      files:
        - pcpm.lock
    paths:
      - .cache/pcpm
  before_script:
    - dotnet tool install --global pcpm
  script:
    - pcpm ci
    - dotnet build --no-restore -c Release
    - dotnet test --no-build -c Release

Caching notes

  • Cache key: pcpm.lock is the right granularity. A change in the lockfile invalidates the cache, by design.
  • Cache path: This is the global store. On Linux it’s ~/.local/share/pcpm/store; on macOS, ~/Library/Application Support/pcpm/store; on Windows, %LOCALAPPDATA%\pcpm\store. Use pcpm store path to get the exact value at runtime.
  • Also cache ~/.nuget/packages: the hardlinked layout means this is small (a few hundred MB on large solutions), but caching it makes dotnet restore itself a no-op.
  • Don’t cache across branches in PR builds: a PR that adds a dependency shouldn’t pollute the main branch’s cache. Most CI providers handle this automatically (the cache key includes the branch).

Local pre-flight

Before pushing, you can run the same command locally:

pcpm ci

If it passes locally, it will pass on CI. This is intentional — pcpm ci is the same binary in both environments.