Concurrency & Caching
How the pipeline avoids redundant work -- cancel-in-progress, Turborepo affected filtering, BuildKit cache layers, and pnpm cache.
The pipeline uses four mechanisms to avoid redundant work: cancel-in-progress concurrency groups, Turborepo's --affected flag, BuildKit layer caching, and pnpm store caching.
Cancel-in-Progress
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
The concurrency group is keyed by branch ref (ci-refs/heads/main, ci-refs/pull/42/merge). When a new push arrives for a branch that already has a running workflow, the in-progress run is canceled and the new run starts.
Why this matters
Rapid pushes are common in AI agent development workflows where Claude Code may push, see a failure, fix it, and push again within minutes. Without cancel-in-progress, each push would queue a separate ~3.5 minute run. With 5 rapid pushes, that is ~17.5 minutes of queued runs where only the latest matters.
Cancel-in-progress ensures only the most recent commit for each branch is being tested.
Edge case: main branch deploys
On main, cancel-in-progress means a new merge cancels an in-progress deploy. This is acceptable because:
- The newer commit includes all changes from the older commit (linear history on main)
- The newer deploy will deploy the superset of changes
- Partial deploys (image pushed but not pulled) are harmless -- the next deploy completes the update
If a deploy-prod SSH session is interrupted mid-command, the VM is left in whatever state it reached. Docker Compose is idempotent -- the next docker compose up -d will reach the correct state.
Turborepo --affected
Three steps in the quality job use Turborepo's --affected flag:
pnpm turbo lint --affected
pnpm turbo typecheck --affected
pnpm turbo test --affected
pnpm turbo build --affected
The --affected flag compares the current commit against the merge base and only runs tasks for packages that have changed files (or packages that depend on changed packages). In a monorepo with 14+ packages, this skips significant work when a change only touches one package.
How affected detection works
Turborepo uses Git to determine which files changed between the current commit and the merge base (main for PRs, the previous commit for pushes to main). It maps changed files to their owning packages using the workspace definitions in pnpm-workspace.yaml, then adds any packages that transitively depend on the changed packages.
For example, changing a file in packages/db also marks packages/api as affected (because @repo/api depends on @repo/db), which marks apps/web as affected (because @repo/web depends on @repo/api).
What is not affected-filtered
Three checks run on the full monorepo regardless of which files changed:
pnpm format:check-- Prettier checks all files (fast, ~5 seconds)pnpm dep-cruise-- dependency graph is global (one violation anywhere fails)pnpm lint:dead-code-- Knip analyzes the full dependency graphpnpm lint:duplication-- jscpd scans all code for duplicates
These checks are fast enough that affected-filtering would add complexity without meaningful time savings.
BuildKit Cache (Docker)
The build-push job uses GitHub Actions cache for Docker layer caching:
cache-from: type=gha
cache-to: type=gha,mode=max
How BuildKit caching works
Docker BuildKit tracks which layers in the multi-stage Dockerfile have changed inputs. When a layer's inputs (files copied, commands run) match a cached version, BuildKit reuses the cached output instead of rebuilding.
The apps/web/Dockerfile is structured for maximum cache reuse:
| Stage | Cached when... | Rebuilds when... |
|---|---|---|
base | Always (base image rarely changes) | Node.js major version bump |
deps | pnpm-lock.yaml unchanged | Any dependency added/removed/updated |
builder | N/A (always rebuilds on code change) | Any source file change |
runner | N/A (depends on builder output) | Builder output changes |
The heavy operation is pnpm install in the deps stage. Because this stage only copies pnpm-lock.yaml and workspace package.json files (not source code), it is cached on most pushes. Typical builds only rebuild the builder and runner stages.
Cache storage
type=gha stores cache layers in GitHub Actions' built-in cache (10 GB limit per repository). mode=max exports all layers (not just the final image), maximizing cache hits for intermediate stages.
pnpm Store Cache
The actions/setup-node@v4 action with cache: "pnpm" caches the pnpm content-addressable store between workflow runs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".node-version"
cache: "pnpm"
This means pnpm install --frozen-lockfile only downloads packages that are not already in the cached store. On a typical run with no dependency changes, this step takes ~10 seconds instead of ~45 seconds.
The cache is shared across all jobs in the workflow (quality, docs, build-push, migrate-prod, deploy-prod). Each job that runs pnpm install benefits from the same cached store.
GitHub Actions Free Tier Budget
At ~3.5 minutes per run, the free tier (2,000 minutes/month) allows roughly 570 runs per month. Cancel-in-progress and --affected filtering are the primary mechanisms that keep usage within budget for a solo developer.
| Scenario | Minutes consumed |
|---|---|
| 20 pushes/day, no cancellations | ~70 min/day, ~2,100 min/month (over budget) |
| 20 pushes/day, 50% canceled | ~35 min/day, ~1,050 min/month (within budget) |
| 10 pushes/day, some affected-only | ~25 min/day, ~750 min/month (comfortable) |
The docs job (5 min timeout) runs in parallel with quality and does not add to the wall-clock time, but it does consume its own minutes from the budget.