A good pipeline should be boring. It runs, it passes, it deploys, and nobody has to think about it. A pipeline that gets attention is a pipeline with a problem.
Here is the structure I reuse across projects, and the reasoning behind each choice.
The four core parts
- Lint and type check on every pull request
- Run tests in parallel where possible
- Build once, then reuse the output for deployment
- Deploy only from the main branch
The third rule is the one most people skip. If you build during the test job and then build again during the deploy job, you are deploying a different artifact from the one you just tested. The difference is usually tiny, right up until the day it is not.
The test workflow
name: CI
on:
pull_request:
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
A few details worth calling out:
The concurrency block cancels older runs when you push repeatedly to the same branch. Without it every small fix adds another queued job, and you end up waiting on results for code that is already out of date.
--frozen-lockfile forces an install of exactly the versions in the lock file. If package.json and the lock file disagree, the command fails instead of quietly installing something else. This is the thing that prevents “works on my machine, breaks in CI”.
cache: pnpm inside setup-node handles the package store cache without hand-writing an actions/cache step.
Caching is what makes the difference
The gap between a 30 second pipeline and a 5 minute one is mostly caching.
There are two things worth caching:
The package store. Already handled by cache: pnpm above.
Intermediate build output. On larger projects, caching the build directory means later runs only compile what changed:
- uses: actions/cache@v4
with:
path: |
.astro
node_modules/.vite
key: build-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
restore-keys: |
build-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
restore-keys is the important part: when no exact match is found, it falls back to the most recent cache with the same prefix. You still get most of the benefit instead of rebuilding from zero.
Running tests in parallel
Once the test suite gets big enough, split it with a matrix:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
# ... setup steps
- run: pnpm test --shard=${{ matrix.shard }}/4
fail-fast: false is worth turning on. By default GitHub kills the whole matrix as soon as one leg fails, which means you only see one failure per run. Turn it off and you get the full picture and fix everything in one pass.
Separating build and deploy
name: Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ... setup
- run: pnpm build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy to Cloudflare Pages
run: npx wrangler pages deploy dist --project-name=my-site
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
The deploy job never checks out the source. It downloads exactly the artifact the build job produced, so what ships is guaranteed to be what was tested.
The environment: production line lets you add a manual approval step in the repository settings, and restricts your secrets so only jobs in that environment can read them.
Securing the pipeline
Pin actions by version. Use actions/checkout@v4, not @main. On sensitive projects, pin to the full commit SHA so an action update cannot quietly change how your pipeline behaves.
Limit the default permissions. The GitHub Actions token has more permissions than it needs by default. Narrow it at the top of the file:
permissions:
contents: read
Then grant specific permissions to the jobs that actually need them.
Be careful with pull_request_target. This event runs with write permissions and can read secrets, even when the pull request comes from a fork. Only use it if you understand the consequences, and never check out and run fork code in that context.
Start small
Do not try to build the perfect pipeline on day one. I usually start with just two steps: install dependencies and build. That alone catches most real problems, things like a file you forgot to commit or a case-sensitive import path that never failed on Windows.
Add linting, add tests, add automated deploys as you need them. A simple pipeline that runs consistently is worth far more than a complex one the team has learned to click past whenever it goes red.
Automation is most valuable when nobody has to think about it any more.