Back to Blog
Tutorial

GitHub Actions CI/CD: Automate Testing Before Every Deployment

Daniel Brooks9 min read
GitHub Actions CI/CD: Automate Testing Before Every Deployment

Automatic deployment on every git push is one of the most productive workflows a development team can adopt. It removes the manual deployment step, reduces human error, and keeps your production environment in sync with your main branch at all times.

But automatic deployment without automated testing first is a liability. Every push goes to production, which means every broken commit, every failing test, every unreviewed dependency vulnerability goes to production too.

This guide shows you how to set up a GitHub Actions CI pipeline that runs your test suite, linting, and security checks before each deployment. The goal is a clean separation of concerns: GitHub Actions handles quality control, and your deployment platform handles the deployment.

The CI/CD Pipeline

Continuous integration and continuous deployment are often discussed together, but they serve distinct purposes.

Continuous integration (CI) is the practice of automatically running tests and checks every time code is pushed. The goal is to catch problems as close to the source as possible — immediately after a commit, not hours later during a manual review.

Continuous deployment (CD) is the practice of automatically deploying code to production when it passes all checks. The goal is to eliminate the manual release step.

When you connect a GitHub repository to Out Plane and configure a deployment branch, Out Plane handles the CD side automatically. Every push to that branch triggers a new deployment: Queued → Building → Deploying → Ready. No manual action required.

The gap this guide fills is the CI side. Without a CI pipeline in front of that auto-deploy, your flow looks like this:

Push to main → Auto-deploy (always, regardless of test results)

With GitHub Actions configured as a quality gate, the flow becomes:

Push to main → GitHub Actions (lint, test, scan) → Pass → Auto-deploy
                                                  → Fail → No deploy, fix required

Branch protection rules complete the picture by preventing broken code from reaching the deployment branch in the first place. When a pull request's CI checks fail, the merge is blocked.

Continuous deployment without continuous integration is just continuous deployment of bugs.

What You'll Set Up

By the end of this guide, you will have:

  • Automated test execution on every push and pull request
  • Linting and code quality checks
  • Security scanning for known vulnerabilities
  • Branch protection rules that enforce passing CI before merge
  • Dependency caching for fast pipeline execution
  • A production-ready workflow file you can adapt for any project

Step 1: Basic Test Workflow

Create the directory .github/workflows/ in your repository root if it does not already exist. Add a file named ci.yml.

Here is a complete workflow for a Node.js application:

yaml
name: CI

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

jobs:
  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 run lint

      - run: npm test

This workflow runs on two triggers: pushes to main and pull requests targeting main. The cache: 'npm' option tells the runner to cache the node_modules directory between runs, which cuts install time significantly on repeated runs.

npm ci is used instead of npm install because it installs exactly the versions recorded in package-lock.json. This makes builds deterministic and prevents dependency drift between your local machine and CI.

Python variant

For a Python application using pytest:

yaml
name: CI

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

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: 'pip'

      - run: pip install -r requirements.txt

      - run: ruff check .

      - run: pytest

Go variant

For a Go application:

yaml
name: CI

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

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'
          cache: true

      - run: go vet ./...

      - run: go test ./...

Step 2: Add Database Testing

Applications that interact with a database need a database available during tests. GitHub Actions supports service containers — Docker containers that run alongside your job and are accessible over localhost.

Here is the Node.js workflow extended with a PostgreSQL service:

yaml
name: CI

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

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    env:
      DATABASE_URL: postgres://postgres:test@localhost:5432/test_db

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - run: npm run lint

      - run: npm run db:migrate

      - run: npm test

The options block configures a health check so GitHub Actions waits until PostgreSQL is ready to accept connections before running your steps. Without this, your migration step would fail with a connection error.

The DATABASE_URL environment variable at the job level makes it available to all steps. Your test suite reads this variable the same way it reads any environment variable in production.

For Redis, add a second service alongside PostgreSQL:

yaml
services:
  postgres:
    image: postgres:17
    env:
      POSTGRES_PASSWORD: test
      POSTGRES_DB: test_db
    ports:
      - 5432:5432
    options: >-
      --health-cmd pg_isready
      --health-interval 10s
      --health-timeout 5s
      --health-retries 5

  redis:
    image: redis:7
    ports:
      - 6379:6379
    options: >-
      --health-cmd "redis-cli ping"
      --health-interval 10s
      --health-timeout 5s
      --health-retries 5

Step 3: Security Scanning

Security checks are easy to skip when you are moving fast. Automating them in CI removes the willpower requirement — they run on every push whether you remember to run them locally or not.

Dependency vulnerability scanning

For Node.js, npm audit checks your installed dependencies against a database of known vulnerabilities:

yaml
- name: Audit dependencies
  run: npm audit --audit-level=high

The --audit-level=high flag fails the job only for high-severity vulnerabilities. Use --audit-level=moderate for stricter enforcement.

For Python:

yaml
- name: Install pip-audit
  run: pip install pip-audit

- name: Audit dependencies
  run: pip-audit

CodeQL static analysis

GitHub's CodeQL action performs static analysis to find security vulnerabilities in your source code. It detects issues like SQL injection vectors, unsafe deserialization, and cross-site scripting risks.

Add a separate job to your workflow:

yaml
codeql:
    runs-on: ubuntu-latest
    permissions:
      security-events: write
      actions: read
      contents: read

    steps:
      - uses: actions/checkout@v4

      - uses: github/codeql-action/init@v3
        with:
          languages: javascript

      - uses: github/codeql-action/autobuild@v3

      - uses: github/codeql-action/analyze@v3

Replace javascript with python, go, java, ruby, or csharp as appropriate for your project.

Dependency review on pull requests

The dependency review action checks pull requests for newly introduced vulnerable dependencies before they are merged. It runs only on pull requests, which is the right place to catch new dependencies being added:

yaml
dependency-review:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'

    steps:
      - uses: actions/checkout@v4

      - uses: actions/dependency-review-action@v4

This action compares the dependency manifest in the base branch against the head branch and flags packages with known CVEs.

Step 4: Branch Protection

Workflow files alone do not prevent broken code from reaching your deployment branch. A developer can push directly to main, bypassing pull requests and CI entirely. Branch protection rules enforce the policy at the repository level.

To configure branch protection on GitHub:

  1. Go to your repository on GitHub
  2. Navigate to Settings > Branches
  3. Under Branch protection rules, click Add rule
  4. Set Branch name pattern to main
  5. Enable Require a pull request before merging
  6. Enable Require status checks to pass before merging
  7. In the search box that appears, add your CI job names (e.g., test, codeql)
  8. Enable Require branches to be up to date before merging
  9. Optionally enable Require review from Code Owners
  10. Click Save changes

With these rules in place, no pull request can be merged to main until all required status checks report success. Direct pushes to main can also be blocked by enabling Restrict pushes that create matching branches.

The result: the only way code reaches main is through a pull request with passing CI. And since Out Plane deploys on push to main, the only code that auto-deploys is code that has passed all your checks.

Step 5: Environment-Specific Workflows

Not every check needs to run at the same speed. Slow checks during local iteration add friction. Fast feedback on pull requests builds confidence. Structuring your workflows by context lets you optimize both.

Fast checks on pull requests

Pull requests benefit from fast feedback — developers are waiting on the result. Keep the PR workflow focused on the checks that catch the most common problems:

yaml
name: PR Checks

on:
  pull_request:
    branches: [main]

jobs:
  lint-and-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 run lint

      - run: npm run type-check

      - run: npm test -- --coverage

Full pipeline on main

The main branch workflow can afford to be more thorough since it runs after the merge decision is made:

yaml
name: CI

on:
  push:
    branches: [main]

jobs:
  test:
    # ... full test suite with database services

  codeql:
    # ... static analysis

  dependency-review:
    # ... skipped on push, only runs on PRs

Dependency caching

Cache your dependency installation across runs. GitHub Actions caches are keyed by a hash of the lock file, so the cache is automatically invalidated when dependencies change:

yaml
- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'

For Python:

yaml
- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: 'pip'

Cached installs typically complete in 5-15 seconds instead of 30-90 seconds, which adds up significantly across dozens of daily runs.

Advanced: Multi-Stage Pipeline

As your test suite grows, parallel jobs reduce total pipeline time. Jobs that do not depend on each other can run simultaneously.

Parallel jobs

yaml
name: CI

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

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
      - run: npm run type-check

  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    env:
      DATABASE_URL: postgres://postgres:test@localhost:5432/test_db
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run db:migrate
      - run: npm test

  security:
    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 audit --audit-level=high

lint, test, and security now run in parallel. Total pipeline time equals the duration of the slowest job, not the sum of all jobs.

Matrix testing

Matrix builds test your application against multiple runtime versions simultaneously. This is useful for libraries or applications that need to support a range of Node.js or Python versions:

yaml
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

This creates three parallel jobs, one per Node.js version. All three must pass for the workflow to succeed.

The Complete Workflow

Here is a production-ready ci.yml that combines all the pieces above. This is a practical starting point for a Node.js application with a PostgreSQL database:

yaml
name: CI

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

jobs:
  lint:
    name: Lint and type check
    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

      - run: npm run type-check

  test:
    name: Test
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    env:
      DATABASE_URL: postgres://postgres:test@localhost:5432/test_db
      NODE_ENV: test

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Run database migrations
        run: npm run db:migrate

      - name: Run tests
        run: npm test -- --coverage

  security:
    name: Security scan
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Audit dependencies
        run: npm audit --audit-level=high

  dependency-review:
    name: Dependency review
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'

    steps:
      - uses: actions/checkout@v4

      - uses: actions/dependency-review-action@v4

  codeql:
    name: CodeQL analysis
    runs-on: ubuntu-latest
    permissions:
      security-events: write
      actions: read
      contents: read

    steps:
      - uses: actions/checkout@v4

      - uses: github/codeql-action/init@v3
        with:
          languages: javascript

      - uses: github/codeql-action/autobuild@v3

      - uses: github/codeql-action/analyze@v3

Commit this file as .github/workflows/ci.yml and push it to your repository. GitHub will detect the file and start running the workflow immediately.

How This Works with Auto-Deploy

Once your CI pipeline is in place and branch protection rules are configured, the full flow looks like this:

A developer opens a pull request:

  1. GitHub Actions runs the CI workflow against the PR branch
  2. Lint, test, and security jobs run in parallel
  3. Results appear as status checks on the pull request
  4. If any job fails, the merge is blocked
  5. The developer fixes the issue, pushes again, and CI re-runs

The pull request is approved and merged to main:

  1. The merge push triggers the CI workflow again on main
  2. If CI passes (it should, since the PR checks already passed), the push stays on main
  3. Out Plane detects the push to the configured branch
  4. A new deployment begins: Queued → Building → Deploying → Ready
  5. Your updated application is live

A push fails CI:

  1. GitHub Actions reports a failure
  2. The push is already on main at this point — CI runs after the push, not before
  3. The deployment still triggers on Out Plane

This is why branch protection on pull requests matters more than CI on direct pushes to main. The real quality gate is preventing broken code from reaching main in the first place. If your team enforces the pull request workflow, code on main has already passed CI by the time it triggers auto-deploy.

Your deployment platform handles the deployment. GitHub Actions handles the quality gate. The two systems complement each other without overlap.

Monitoring Your Pipeline

GitHub Actions dashboard

The Actions tab in your GitHub repository shows all workflow runs, their status, duration, and logs. You can drill into any run to see the output of individual steps — useful for debugging flaky tests or slow build steps.

Workflow run notifications

GitHub sends email notifications for failed workflow runs by default. You can configure notification preferences under Settings > Notifications in your GitHub account. For teams, consider routing failure notifications to a shared Slack channel using the slackapi/slack-github-action action.

Build time optimization

Pipeline time directly affects developer velocity. A 10-minute CI run creates a 10-minute feedback loop. Keep an eye on total run time and optimize aggressively:

  • Dependency caching: The single highest-impact optimization. Use cache: 'npm' or cache: 'pip' on setup actions.
  • Parallel jobs: Split lint, test, and security into separate parallel jobs instead of running them sequentially.
  • Test splitting: For large test suites, use a test splitting tool to distribute tests across multiple parallel runners.
  • Skip unchanged paths: Use paths filters on workflow triggers to skip CI for documentation-only changes:
yaml
on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'

Target pipeline times:

  • Under 3 minutes: good
  • 3-7 minutes: acceptable, worth optimizing
  • Over 7 minutes: investigate — developers will start skipping reviews to avoid waiting

Summary

Automatic deployment is a productivity multiplier. Automatic deployment without automated testing is a risk amplifier. GitHub Actions CI bridges the gap.

Here is what this guide covered:

  • Basic workflow: ci.yml with lint and test steps, running on push and pull request
  • Database testing: Service containers for PostgreSQL and Redis available during test runs
  • Security scanning: npm audit, CodeQL static analysis, and dependency review
  • Branch protection: Requiring CI status checks before pull request merges
  • Environment-specific workflows: Fast PR checks, full pipeline on main, dependency caching
  • Parallel jobs: Running lint, test, and security simultaneously to reduce total pipeline time
  • Auto-deploy integration: How CI and Out Plane's auto-deploy work together as a complete pipeline

The workflow files in this guide are practical starting points. Adjust the test commands, Node.js version, and service configuration to match your application. The structure — lint, test, security, all in parallel, all required before merge — applies regardless of language or framework.

For next steps on the deployment side, see how to deploy a Next.js application or deploy a Docker application on Out Plane. If you are optimizing an existing deployment, the zero-downtime deployment guide covers rolling updates and health check configuration.

Ready to connect your repository and enable auto-deploy? Get started with Out Plane — GitHub OAuth, no CLI required.


Tags

github-actions
ci-cd
testing
deployment
automation
devops

Start deploying in minutes

Connect your GitHub repository and deploy your first application today. $20 free credit. No credit card required.