GitHub Actions can automate your software development workflows, from building and testing to deploying your code.

Let’s see it in action. Imagine you have a simple Node.js project. You’ve pushed your code to GitHub, and now you want to automatically run your tests every time someone opens a pull request.

Here’s a workflow file (.github/workflows/ci.yml) that does just that:

name: Node.js CI

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

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3
    - name: Use Node.js 16.x
      uses: actions/setup-node@v3
      with:
        node-version: '16.x'
        cache: 'npm'
    - run: npm ci
    - run: npm test

When you push to main or open a pull request targeting main, this workflow kicks off. The on block defines the triggers. jobs are the fundamental units of work. Here, we have a single job named build.

The runs-on key specifies the virtual machine environment. ubuntu-latest is a common choice, providing a Linux environment. The steps are the individual commands or actions that run within the job.

actions/checkout@v3 is a pre-built action that checks out your repository’s code so the workflow can access it. actions/setup-node@v3 is another action that sets up the specified Node.js version on the runner. The cache: 'npm' option tells it to cache your npm dependencies, making subsequent runs much faster.

run: npm ci installs your project’s dependencies using npm ci (clean install), which is generally preferred in CI environments as it installs exact versions from the package-lock.json file. run: npm test then executes your project’s test script as defined in your package.json.

This setup ensures that any code merged into main has passed automated tests, preventing regressions.

The true power comes from chaining these jobs together, creating complex pipelines. You can define dependencies between jobs using the needs keyword. For example, after your tests pass, you might want to build a Docker image.

name: Node.js Build and Deploy

on:
  push:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - name: Use Node.js 16.x
      uses: actions/setup-node@v3
      with:
        node-version: '16.x'
        cache: 'npm'
    - run: npm ci
    - run: npm test

  build_docker:
    runs-on: ubuntu-latest
    needs: test # This job runs only after the 'test' job succeeds
    steps:
    - uses: actions/checkout@v3
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v2
    - name: Login to Docker Hub
      uses: docker/login-action@v2
      with:

        username: ${{ secrets.DOCKERHUB_USERNAME }}


        password: ${{ secrets.DOCKERHUB_TOKEN }}

    - name: Build and push Docker image
      uses: docker/build-push-action@v4
      with:
        context: .
        push: true
        tags: your-dockerhub-username/your-app:latest

Here, the build_docker job has needs: test. This means build_docker will only start running if the test job completes successfully. This creates a sequential dependency.

The docker/setup-buildx-action@v2 action prepares the runner for building Docker images. The docker/login-action@v2 authenticates with Docker Hub using credentials stored as GitHub Secrets (secrets.DOCKERHUB_USERNAME and secrets.DOCKERHUB_TOKEN). These secrets are encrypted environment variables you configure in your GitHub repository’s settings, keeping sensitive information out of your workflow files.

The docker/build-push-action@v4 then builds your Docker image using the Dockerfile in your repository’s root (context: .), pushes it to Docker Hub (push: true), and tags it as your-dockerhub-username/your-app:latest. You’d replace your-dockerhub-username and your-app with your actual Docker Hub username and application name.

You can also run jobs in parallel if they don’t depend on each other. By default, jobs in the same workflow file run in parallel unless needs is specified. This is how you can speed up your CI/CD by performing multiple tasks concurrently.

GitHub Actions uses a matrix strategy to run your jobs across different configurations. For instance, you might want to test your application on multiple Node.js versions or operating systems.

name: Node.js Matrix Test

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

jobs:
  build:

    runs-on: ${{ matrix.os }}

    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node-version: [14.x, 16.x, 18.x]

    steps:
    - uses: actions/checkout@v3

    - name: Use Node.js ${{ matrix.node-version }}

      uses: actions/setup-node@v3
      with:

        node-version: ${{ matrix.node-version }}

        cache: 'npm'
    - run: npm ci
    - run: npm test

The strategy.matrix defines the combinations. In this example, the build job will run 6 times: once for each combination of os (Ubuntu and Windows) and node-version (14.x, 16.x, 18.x). The runner dynamically creates jobs based on these matrix values, substituting them into runs-on and the setup-node action.

The truly remarkable aspect of GitHub Actions is its extensibility through the Actions marketplace. You can find thousands of pre-built actions for almost any task imaginable, from deploying to AWS, Azure, or GCP, to integrating with Slack, or even generating code documentation. This means you rarely have to write complex scripts from scratch.

However, managing complex workflows with many jobs and steps can become unwieldy. If a job fails, you might only see the error from the first failing step in the output, and debugging can involve a lot of scrolling.

The next logical step is to explore using self-hosted runners for more control over your build environment.

Want structured learning?

Take the full DevOps & Platform Engineering course →