Every GitHub Actions workflow lives in .github/workflows/ and settles three things: the trigger, the machine, and the order of steps. The YAML below runs lint, typecheck, test, and build for a Node project on pnpm.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test --coverage
- run: pnpm build
Four steps cover what usually breaks before merge: lint catches formatting, typecheck catches bad types, the test suite catches regressions, and the build catches a broken bundle. The rest of the file handles team workflow and infrastructure.
Dependency cache
Without cache, every run installs all dependencies from scratch and loses 2 to 3 minutes doing it. You switch the cache on in setup-node, which uses the lockfile hash as the key:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm' # key: lockfile hash
Change the lockfile and the runner installs again. An npm project sets cache: 'npm' and a yarn project sets cache: 'yarn'. The GitHub Actions caching guide documents the key, the cache path, and how restores behave.
CI and deploy in separate jobs
Verification runs on every pull request. Deploy runs when code reaches main. Two jobs tied by needs and gated by if hold that line:
jobs:
ci:
runs-on: ubuntu-latest
steps:
# lint, typecheck, test, build
deploy:
needs: ci # waits for the CI job
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production # manual approval when configured
steps:
- uses: actions/checkout@v4
- name: Deploy to production
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: ./scripts/deploy.sh
environment: production ties the deploy to repository environments. You list required reviewers there, and the deploy waits for someone to approve before it starts. The environments documentation covers environment rules and branch rules.
Secrets outside the YAML
A credential written into the YAML lands in Git history and in every execution log. You store the value in Settings > Secrets and variables > Actions and inject it only in the step that needs it:
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: ./scripts/deploy.sh
Environment secrets split values per environment and inherit the approval rules of the environment. The secrets guide defines the scope of each value and what happens when you echo a secret into a log.
Matrix builds across Node versions
A library that promises support for Node 18, 20, and 22 has to prove it on every PR. The matrix runs the same job in parallel, one run per version:
jobs:
ci:
strategy:
matrix:
node-version: [18, 20, 22]
runs-on: ubuntu-latest
name: Test on Node ${{ matrix.node-version }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
The matrix documentation covers fail-fast, which cancels the remaining runs once one fails, and the list of excluded combinations.
Artifacts between jobs
The deploy job consumes the dist/ folder that CI produced. Upload and download move that folder across:
# in the ci job
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
# in the deploy job
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
Coverage reports take the same path: one upload with name: coverage-report keeps the HTML report alive after the run ends.
The complete pipeline
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test --coverage
- run: pnpm build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: ci
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./scripts/deploy.sh
The file is 88 lines and covers caching, the split between verification and deploy, artifacts across jobs, and environment protection. Most projects stop the pipeline here.
References
- Workflow syntax for GitHub Actions (GitHub Docs)
- Caching dependencies to speed up workflows (GitHub Docs)
- Using environments for deployment (GitHub Docs)
- Using secrets in GitHub Actions (GitHub Docs)
- Using a matrix for your jobs (GitHub Docs)
Enjoyed this content?
I build web products and AI solutions the right way — solid architecture, maintainable code, and real delivery.
Let's talk