Building an Automated CI/CD Pipeline with GitHub Actions
This sets up a GitHub Actions pipeline that builds, tests across versions, and deploys on every push, with secrets and permissions handled correctly from the start.
Triggers: deciding when the pipeline runs
Every workflow starts with on:. For a typical web app, you want two triggers: push to your main branch (so merges deploy automatically) and pull_request (so you get build/test feedback before merging, not after).
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: readThat permissions block at the top level is deliberate, not boilerplate.
GitHub's default GITHUB_TOKEN permissions used to be broad (write on almost everything) unless your organization changed the default. Always set permissions explicitly in the workflow, even if it just says contents: read.
The build and test job
This is the job every PR runs. Keep it deterministic: pin your runtime version, cache dependencies, and fail loudly.
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test -- --ci
- name: Build
run: npm run buildnpm ci instead of npm install: it installs exactly what's in the lockfile and fails if the lockfile is out of sync, instead of quietly rewriting it.
Secrets: using them without leaking them
Sooner or later this job needs a real credential (an API key for a third-party service during tests, or deploy credentials later in the pipeline). GitHub Actions gives you secrets.* for this, and there are exactly two rules that matter.
First, never echo a secret, even for debugging. run: echo ${{ secrets.API_KEY }} prints it straight into the build log. That log is visible to anyone with read access to the repo, and it sticks around for the whole retention period. Second, pass secrets as environment variables into the step that needs them, not as inline command arguments. Command-line arguments can end up in process listings or shell history captured elsewhere in the log.
- name: Run integration tests
env:
API_KEY: ${{ secrets.API_KEY }}
run: npm run test:integrationGitHub does mask registered secret values in logs automatically. But masking is just a string match: transform the secret first (base64-encode it, split it, log it inside a JSON blob) and the mask can miss it.
Never use pull_request_target to run untrusted code from a fork with access to your secrets. pull_request_target runs with the permissions and secrets of the base repository. Combine that with checking out and running a fork's code, and you get a well-known way to exfiltrate secrets from public repos. If you need to test fork PRs, use pull_request (no secrets, restricted token) and a separate, manually-approved workflow for anything that needs real credentials.
Testing across versions with a matrix
If you maintain a library, or you're not sure which runtime version to standardize on yet, a matrix build runs the same job across multiple configurations in parallel instead of you maintaining copy-pasted jobs.
jobs:
build-and-test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: ["18", "20", "22"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm test -- --ci
- run: npm run buildThis produces three parallel jobs (build-and-test (18), (20), (22)). It's the fastest way to catch "works on Node 20, breaks on Node 18" before a user reports it.
Passing artifacts between jobs
The build output from the test job is often exactly what you want to deploy; no reason to rebuild it in the deploy job and risk it producing something slightly different. Upload it as an artifact, then download it in the job that needs it.
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: production-build
path: dist/
retention-days: 1And in the deploy job:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: production-build
path: dist/The deploy job: gated, not automatic-by-default
Deployment should depend on the build/test job succeeding, and ideally on an explicit approval gate for production. needs: handles the first part; a GitHub environment with required reviewers handles the second.
jobs:
deploy:
needs: build-and-test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: production-build
path: dist/
- name: Deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./scripts/deploy.shThe if condition means the deploy job only runs on pushes to main. The environment: production line is what lets you configure required reviewers and deployment branch restrictions in the repo settings. Add branch protection on main (require the build-and-test check to pass, require a review before merge), and the loop closes: no untested code reaches main.
Put together, the pipeline looks like this:
Get the triggers, the fail-on-red test job, and the secrets handling right once, and every later addition, whether staging, canary, or a Slack ping, just hangs off the same graph.
Want to actually run this in production?
This tutorial covers the concepts and architecture. If you want to implement it in your own infrastructure, or get good enough to own this problem long-term, I offer 1:1 mentoring built around your real environment, not a generic course.
This tutorial
- Core architecture & key concepts
- Illustrative code snippets
- The reasoning behind each decision
1:1 mentoring
- Working sessions on your own environment
- Direct answers to the edge cases you're hitting
- Feedback on your actual implementation
- Ongoing support as you build it out