A Complete GitHub Actions Example

Concurrency groups, cancel-in-progress, Turborepo remote cache in CI, and workflow dispatch — the patterns that make GitHub Actions production-ready.

March 22, 20265 min read3 / 3

The theory is straightforward. The practice is where the details live. Here's a production-shaped GitHub Actions workflow for a monorepo, with explanations of the parts that aren't obvious.

Concurrency and Cancel-in-Progress

Without concurrency control, every push to a branch queues up another workflow run. If you push five commits quickly, you have five runs queued, and the earlier ones are now irrelevant — they're checking code you've already moved past.

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

This cancels any in-progress run for the same workflow + branch combination when a new run starts. Only the latest commit gets fully checked.

One exception: cancel-in-progress should usually be false for runs on the main branch. You want every merge to main to complete, even if another merge happened before it finished.

YAML
concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

Turborepo Remote Cache in CI

Turborepo's local cache is fast. The remote cache makes it fast across machines — including CI.

YAML
env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }}

With these environment variables set, Turborepo checks the remote cache before running any task. A package that was built on the previous run — with identical inputs — skips the build entirely and downloads the cached output.

The first run on a branch builds everything. Subsequent runs skip unchanged packages. CI runs for small changes go from minutes to seconds.

A Full Workflow

YAML
name: CI on: push: branches: [main, 'release/*'] pull_request: branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} jobs: ci: name: CI runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 2 # Needed for Turborepo to detect changed packages - uses: pnpm/action-setup@v4 with: version: 9 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' - name: Install dependencies run: pnpm install --frozen-lockfile - name: Lint run: pnpm turbo lint - name: Type check run: pnpm turbo typecheck - name: Test run: pnpm turbo test - name: Build run: pnpm turbo build - name: Check bundle size run: npx bundlesize if: github.event_name == 'pull_request'

The fetch-depth: 2 in the checkout step is important for Turborepo — it needs access to the previous commit to determine which packages changed.

Separating Checks by Trigger

Not everything should run on every trigger. Separating workflows by event keeps each workflow focused:

YAML
# .github/workflows/pr.yml — runs on PRs only on: pull_request: branches: [main] jobs: bundle-size: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: pnpm install - run: pnpm build - run: npx bundlesize --github-token ${{ secrets.GITHUB_TOKEN }}
YAML
# .github/workflows/scheduled.yml — runs on a schedule on: schedule: - cron: '0 8 * * 1-5' # Weekdays at 8am UTC workflow_dispatch: jobs: lighthouse: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: pnpm install - run: pnpm build - run: npx lhci autorun

workflow_dispatch allows manual triggering from the GitHub Actions UI. This is invaluable for scheduled checks — you don't have to wait for the next Monday morning to test a change to the Lighthouse configuration.

Node Memory in CI

One thing that bites large codebases in CI: the default Node.js heap size is lower than you'd think — around 512MB. TypeScript type checking and large builds can exceed this.

YAML
- name: Type check run: pnpm turbo typecheck env: NODE_OPTIONS: '--max-old-space-size=4096'

Bumping the heap size is sometimes the right fix and sometimes a band-aid over a real problem (more files than TypeScript should be checking, complex type instantiation loops). The diagnostic tools from the previous section help you distinguish between them. But for unblocking CI while you investigate, it's a valid short-term move — as long as you come back to understand why it was needed.

Secrets and Variables

A few naming conventions worth knowing:

  • ${{ secrets.TURBO_TOKEN }} — encrypted, not shown in logs, set in repository settings
  • ${{ vars.TURBO_TEAM }} — unencrypted configuration variable, visible in logs, set in repository settings
  • ${{ secrets.GITHUB_TOKEN }} — automatically provided by GitHub, no setup needed

The GITHUB_TOKEN secret is what allows workflows to post PR comments, create check runs, and interact with the GitHub API without additional authentication setup.

The Maintenance Question

CI workflows have a maintenance cost that's easy to underestimate. They accumulate over time — someone adds a step, someone adds a workflow, the total runtime creeps up. Setting a recurring reminder to audit your CI — even quarterly — is worth it.

The questions I ask during an audit: What's actually failing? (If something never fails, consider whether it's testing the right thing.) What's the slowest step? (Can it run less often, or be parallelized?) What caches aren't being used? (Check the cache hit rates in the Actions logs.)

A CI pipeline is infrastructure. It needs maintenance like any other infrastructure.

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.