What Is GitHub Actions?

GitHub Actions is a CI/CD and workflow automation platform built directly into GitHub. It lets you define automated workflows β€” sequences of tasks triggered by events in your repository β€” using YAML files stored in your codebase at .github/workflows/.

Before GitHub Actions (launched in 2019), most teams used external CI services like Travis CI, CircleCI, or Jenkins. GitHub Actions changed that by putting automation inside GitHub itself: no separate account, no webhook configuration, no external service to maintain.

Free tier: Public repositories get unlimited GitHub Actions minutes. Private repositories get 2,000 free minutes per month on the free plan, with additional minutes billed per-minute.

GitHub Actions is used for: running tests on every pull request, deploying to production on merge, publishing packages to npm or PyPI, scanning for security vulnerabilities, sending notifications, and virtually any other automated task you can script.

Key Concepts: Workflows, Jobs, Steps, Runners

Understanding GitHub Actions requires understanding four core concepts and how they nest inside each other:

  • Workflow: A YAML file in .github/workflows/. One repository can have multiple workflows. Each workflow defines when it runs (triggers) and what it does (jobs).
  • Job: A set of steps that run on the same runner. Jobs within a workflow run in parallel by default. You can configure dependencies to run them sequentially.
  • Step: A single task within a job β€” either a shell command (run:) or an Action (uses:). Steps within a job run sequentially and share the runner's filesystem.
  • Runner: A virtual machine that executes your jobs. GitHub provides Ubuntu, Windows, and macOS runners. You can also host your own self-hosted runners.
  • Action: A reusable unit of workflow logic. Actions are published to the GitHub Marketplace or referenced directly from a repository. actions/checkout@v4 is the most commonly used β€” it clones your repository onto the runner.

Your First Workflow

A minimal workflow that runs tests on every push looks like this:

.github/workflows/ci.yml YAML
name: CI

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

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

Save this file, push to GitHub, and you'll see the workflow appear under the Actions tab in your repository. GitHub automatically detects any .yml or .yaml file in .github/workflows/ and registers it as a workflow.

Triggers: When Workflows Run

The on: key defines what triggers a workflow. GitHub supports over 30 event types. The most commonly used:

  • push β€” runs when commits are pushed to matching branches or tags
  • pull_request β€” runs when a PR is opened, updated, or synchronised
  • schedule β€” runs on a cron schedule (e.g. nightly security scans)
  • workflow_dispatch β€” manual trigger with optional inputs
  • release β€” runs when a GitHub release is published
  • workflow_call β€” allows a workflow to be called as a reusable sub-workflow

Schedule example: cron: '0 2 * * *' runs at 2am UTC every day. GitHub Actions uses standard cron syntax. Scheduled workflows only run on the default branch.

You can combine multiple triggers. A common pattern is to run fast tests on every PR but full integration tests only on merges to main using separate workflow files or job conditions.

Jobs, Steps, and Actions

Jobs run in parallel by default. To make Job B wait for Job A to succeed, use needs::

.github/workflows/deploy.yml YAML
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  build:
    needs: test   # only runs if test succeeds
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build

  deploy:
    needs: build   # only runs if build succeeds
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy to production
        run: ./deploy.sh

Each step in a job runs sequentially. If any step fails, subsequent steps are skipped and the job is marked as failed β€” unless you use continue-on-error: true or if: always() conditions.

Secrets and Environment Variables

Never hardcode credentials in workflow files. GitHub provides encrypted secrets stored at the repository, environment, or organisation level. Access them with ${{ secrets.SECRET_NAME }}.

Secret scanning: GitHub automatically scans pushed commits for known credential patterns. But this catches secrets after the fact β€” use pre-commit hooks and AquilaX scanning to catch them before they're pushed.

For non-sensitive configuration, use environment variables defined at the workflow, job, or step level with the env: key. Secrets can also be passed as environment variables to avoid them appearing in command arguments (which would show in logs):

secrets usage example YAML
steps:
  - name: Deploy
    env:
      API_KEY: ${{  secrets.DEPLOY_API_KEY  }}
    run: ./deploy.sh  # accesses $API_KEY from env, not args

Matrix Builds for Multiple Environments

Matrix builds let you run the same job across multiple configurations β€” Node versions, operating systems, Python versions β€” without duplicating your workflow. GitHub Actions fans out matrix combinations automatically:

matrix build example YAML
jobs:
  test:
    runs-on: ${{  matrix.os  }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: ['18', '20', '22']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{  matrix.node  }}
      - run: npm ci && npm test

This creates 9 jobs (3 OS Γ— 3 Node versions) running in parallel. Use fail-fast: false to let all matrix jobs complete even if one fails, which is useful for identifying which combinations have issues.

Caching Dependencies for Speed

Every runner starts fresh. Without caching, npm install or pip install runs from scratch on every workflow run. The actions/cache action stores and restores directories between runs:

caching npm dependencies YAML
steps:
  - uses: actions/checkout@v4

  - name: Cache node_modules
    uses: actions/cache@v4
    with:
      path: ~/.npm
      key: ${{  runner.os  }}-node-${{  hashFiles('**/package-lock.json')  }}
      restore-keys: |
        ${{  runner.os  }}-node-

  - run: npm ci

The cache key includes a hash of your lock file β€” so any dependency change invalidates the cache. Most language-specific setup actions (actions/setup-node, actions/setup-python) now have built-in caching via a cache: input, which is simpler than using actions/cache directly.

Real-World CI/CD Pipeline Example

Here's a complete pipeline that tests, builds, scans for vulnerabilities, and deploys a Node.js application:

.github/workflows/pipeline.yml YAML
name: Full CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read   # least-privilege: only what's needed

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 && npm test

  security-scan:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run AquilaX security scan
        uses: aquilax/scan-action@v1
        with:
          api-key: ${{  secrets.AQUILAX_API_KEY  }}
          fail-on: critical

  deploy:
    needs: [test, security-scan]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: ./scripts/deploy.sh
        env:
          DEPLOY_TOKEN: ${{  secrets.DEPLOY_TOKEN  }}

GitHub Actions best practices recap: Always pin actions to a specific SHA or tag. Use permissions: to restrict token scope. Store all credentials as secrets. Use environments for production deployments to require manual approval.

Secure Your GitHub Actions Pipelines

AquilaX scans your workflows for hardcoded secrets, expression injection risks, overpermissive tokens, and unpinned actions β€” automatically, on every push.

Start Free Scan