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@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "24"
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: ["22", "24"]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm test -- --ci
- run: npm run buildThis produces two parallel jobs (build-and-test (22) and (24)), one per supported Node.js LTS line. It's the fastest way to catch "works on Node 24, breaks on Node 22" before a user reports it. Keep the matrix to versions that still get security fixes. When a line reaches end-of-life, drop it from the matrix instead of paying CI minutes to keep supporting 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
if: matrix.node-version == '24'
uses: actions/upload-artifact@v7
with:
name: production-build
path: dist/
retention-days: 1The if matters once this step lives inside the matrix job. Artifact names must be unique within a workflow run, so if every matrix leg uploads production-build, the first upload succeeds and the others fail the job. Upload from the one leg that matches the Node.js version you run in production. If you do need every leg's output, put the version in the name instead, e.g. production-build-node${{ matrix.node-version }}, and download that exact name.
And in the deploy job:
- name: Download build artifact
uses: actions/download-artifact@v8
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 # lets the job request a short-lived OIDC token
steps:
- name: Download build artifact
uses: actions/download-artifact@v8
with:
name: production-build
path: dist/
- name: Authenticate to Google Cloud (OIDC)
uses: google-github-actions/auth@v3
with:
project_id: ${{ vars.GCP_PROJECT_ID }}
workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v3
- name: Deploy to bucket
run: gcloud storage rsync dist/ "gs://${{ vars.SITE_BUCKET }}" --recursive --delete-unmatched-destination-objectsThere's no DEPLOY_TOKEN secret here, because id-token: write replaces it. Instead of storing a long-lived cloud key in GitHub, the job asks GitHub for a short-lived OIDC token and exchanges it with Google Cloud through Workload Identity Federation. On the GCP side, you configure the provider to trust only this repository (and ideally only the production environment or the main branch), and grant that identity write access to the one bucket. There is no key to rotate or leak, and the token expires on its own after the job. The three vars.* values are plain repository or environment variables, and none of them is a credential. I'm deploying a static build to a Cloud Storage bucket here; AWS has the same pattern with aws-actions/configure-aws-credentials and role-to-assume.
Only grant id-token: write to the job that needs it. If you ever remove the OIDC step and go back to a static secret, remove the permission too. An unused id-token: write lets a compromised step request cloud tokens it has no reason to have.
The 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