GitHub Actions Workflows I Use on Every Project


Every new project I start gets the same set of GitHub Actions workflows. Over the years, I’ve refined these to cover the essentials without over-engineering the CI pipeline. Here are the workflows I copy into every repository, with explanations of why each decision was made.

The Core CI Workflow

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm test

  build:
    runs-on: ubuntu-latest
    needs: [lint, test]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run build

A few things worth noting about this configuration.

Concurrency groups. The concurrency block ensures that pushing new commits to the same branch cancels in-progress runs. Without this, you’ll waste CI minutes on outdated code every time you push a quick fix.

Separate jobs. Lint, test, and build run as separate jobs. Lint and test run in parallel (no dependencies between them), and build only runs after both pass. This gives faster feedback — if your linting fails, you know in 30 seconds instead of waiting for the full test suite.

npm ci instead of npm install. The ci command installs from the lockfile exactly, which is faster and more deterministic than install. It also deletes node_modules first, ensuring a clean state.

Caching. The cache: 'npm' option on setup-node caches the npm download cache. This speeds up subsequent runs by 15-30 seconds depending on your dependency count.

Dependency Updates

name: Update Dependencies

on:
  schedule:
    - cron: '0 9 * * 1'  # Monday at 9am UTC
  workflow_dispatch:

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npx npm-check-updates -u
      - run: npm install
      - run: npm test
      - uses: peter-evans/create-pull-request@v6
        with:
          title: 'chore: update dependencies'
          branch: deps/weekly-update
          commit-message: 'chore: update dependencies'

This runs weekly and creates a PR with all available dependency updates. The key is that it runs your test suite before creating the PR. If tests fail with updated dependencies, the PR either isn’t created or shows a failing check, so you don’t merge broken updates.

I prefer this approach over Dependabot for most projects because it creates a single PR with all updates rather than flooding you with individual PRs per dependency.

Release Automation

For projects that publish to npm or deploy on merge to main:

name: Release

on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    if: "!contains(github.event.head_commit.message, '[skip ci]')"
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - run: npx semantic-release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Semantic-release automates version bumping and changelog generation based on commit messages. Use conventional commits (feat:, fix:, chore:) and the tool handles the rest.

Secrets Scanning

name: Security

on:
  push:
    branches: [main]
  pull_request:

jobs:
  secrets:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: trufflesecurity/trufflehog@main
        with:
          extra_args: --only-verified

TruffleHog scans your code for accidentally committed secrets — API keys, database passwords, cloud credentials. The --only-verified flag reduces false positives by checking whether detected secrets are actually valid.

This has saved me twice. Once from committing an AWS access key, once from a database connection string that made it into a test file.

Tips I’ve Learned the Hard Way

Pin action versions with full SHA hashes for security-critical workflows. uses: actions/checkout@v4 is convenient but could be compromised if someone gains access to the action repository. For production deployments, pin to a specific commit SHA.

Use timeout-minutes on every job. The default timeout is 6 hours. If your test suite hangs, you’ll burn CI minutes until you notice. I set most jobs to 10-15 minutes.

Test the workflow locally with act before pushing. Debugging GitHub Actions by pushing commits and waiting for results is painfully slow. The act tool runs workflows locally using Docker.

Keep secrets minimal. Every secret stored in GitHub Actions is a potential leak. Use OIDC authentication for cloud providers instead of static credentials where possible. AWS, GCP, and Azure all support GitHub Actions OIDC.

These workflows aren’t fancy, but they catch the majority of issues before they reach production. A reliable CI pipeline that runs in under five minutes is more valuable than an elaborate one that takes thirty minutes and breaks regularly.