Continuous integration (CI) pipelines are an important part of building and deploying reliable software. Whether it's building a Docker image, running tests, or even just doing code linting, CI pipelines help you automate repetitive tasks and ship better code even faster. GitHub Actions lets you create pipelines to build containers, test source code, and publish software.

In this blog post, we'll walk through a GitHub Actions pipeline example, building a Docker image with a Rails application, running tests, and publishing the Docker image. You'll walk away feeling confident that you can build a Rails CI pipeline that fits your needs.

Building Rails continuous integration pipelines

Continuous integration (CI) refers to the best practice of automating the development workflow of multiple contributors introducing code into a project. An automated CI process—or pipeline—removes much of the burden of testing and releasing software. CI pipelines often run on a pull request, which either blocks or allows the merge of a branch into the primary git branch.

In many projects, the biggest benefit of a CI pipeline is that it builds containers that can be used to ship the software. It's also a common practice to run automated tests as part of a CI pipeline to ensure that code that fails tests cannot be merged into the project and cause a regression.

For some projects, a linter will run against the code changes to validate that it matches the project style guidelines automatically. Much of a CI process is intended to build confidence that a change is fit for production, which makes the code review process even easier.

Shipping with GitHub Actions CI pipelines

GitHub Actions are an easy way to introduce a CI pipeline into your process as part of your existing workflow on GitHub. You can start a GitHub Actions workflow as part of several different events on GitHub, including pull requests, code pushes, or even issue creation.

GitHub Actions has starter workflows that make setting up a process for the first time easier, and there's one workflow for Ruby. You'll set up your GitHub continuous integration pipeline as several workflows, written as Infrastructure as Code (IaC), meaning all your configuration is human readable files that live in the repository.

You can follow along with a new project or any Rails application hosted on GitHub.

Building and publishing a Docker image from a Rails CI pipeline

Deployment of code changes to a production environment, specifically continuous deployment, is often the final step in a CI/CD pipeline. If you deploy your application with Docker, you must build a new Docker image before each deployment. Docker uses a Dockerfile—like a blueprint—to create a Docker image, which is then used to create containers that run your application.

Building this image as part of a CI process is a great beginning for automating deploys. With a GitHub CI pipeline, you can build a Docker Image and push it to a registry, which a deploy tool can then pull from to deploy containers.

As of Rails 7.1, a Dockerfile is automatically created with rails new. If your application was created before Rails 7.1, or you don't already have a working Dockerfile to run your application in a container, you'll first want to set up your app to run in Docker.

Building and publishing a Docker image is a repeatable process, so GitHub Actions has presets that we can lean on. Under your repository name, go to the "Actions" tab. We'll add the first workflow in the next section.

Adding GitHub Actions to a Repository Adding GitHub Actions

Select the configure on the "Publish Docker Container" suggested workflow. If it's not suggested for you, you can search for the workflow. Don't fret if you can't find it, as we'll be walking through the configuration file that is generated here. When you click "Configure," GitHub generates a new docker-publish.yml file for your repository:

name: Docker

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.

on:
  schedule:
    - cron: '41 2 * * *'
  push:
    branches: [ "main" ]
    # Publish semver tags as releases.
    tags: [ 'v*.*.*' ]
  pull_request:
    branches: [ "main" ]

env:
  # Use docker.io for Docker Hub if empty
  REGISTRY: ghcr.io
  # github.repository as <account>/<repo>
  IMAGE_NAME: ${{ github.repository }}


jobs:
  build:

    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      # This is used to complete the identity challenge
      # with sigstore/fulcio when running outside of PRs.
      id-token: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      # Install the cosign tool except on PR
      # https://github.com/sigstore/cosign-installer
      - name: Install cosign
        if: github.event_name != 'pull_request'
        uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1
        with:
          cosign-release: 'v2.1.1'

      # Set up BuildKit Docker container builder to be able to build
      # multi-platform images and export cache
      # https://github.com/docker/setup-buildx-action
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0

      # Login against a Docker registry except on PR
      # https://github.com/docker/login-action
      - name: Log into registry ${{ env.REGISTRY }}
        if: github.event_name != 'pull_request'
        uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Extract metadata (tags, labels) for Docker
      # https://github.com/docker/metadata-action
      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      # Build and push Docker image with Buildx (don't push on PR)
      # https://github.com/docker/build-push-action
      - name: Build and push Docker image
        id: build-and-push
        uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      # Sign the resulting Docker image digest except on PRs.
      # This will only write to the public Rekor transparency log when the Docker
      # repository is public to avoid leaking data.  If you would like to publish
      # transparency data even for private images, pass --force to cosign below.
      # https://github.com/sigstore/cosign
      - name: Sign the published Docker image
        if: ${{ github.event_name != 'pull_request' }}
        env:
          # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
          TAGS: ${{ steps.meta.outputs.tags }}
          DIGEST: ${{ steps.build-and-push.outputs.digest }}
        # This step uses the identity token to provision an ephemeral certificate
        # against the sigstore community Fulcio instance.
        run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

Because this configuration file is well-commented, we can quickly go through it to see if it fits our needs. The first section (under the on key) tells GitHub actions when to run this workflow. Right now, it's set to run when a pull request is opened (or reopened) against main, when a push to a specific branch (the main branch) happens, and also every day at 2:41.

We don't need to run this workflow on a schedule, so we'll remove that part from the on section. It should look like this:

on:
  push:
    branches: [ "main" ]
    # Publish semver tags as releases.
    tags: [ 'v*.*.*' ]
  pull_request:
    branches: [ "main" ]

The next section sets environment variables, which we'll leave alone. The section after that gives details on the jobs to run, which contains most of the configuration.

This comes pre-configured with steps that run to install the necessary dependencies, build a Docker image from the repository, and push it to the configured registry. In this project, we're pushing Docker images to GitHub Packages.

One step is to install a tool called cosign-intaller, which you'll want to change to pull the latest version of cosign-installer. The step should now look like this:

# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@v3.5.0
with:
  cosign-release: 'v2.2.4' # optional

After you've made the change to the on section and the Install cosign step, click the "Commit Changes" button, which will create this file in .github/workflows.

Testing this GitHub Actions pipeline example

You should see the running workflow when you click the Actions tab for your repository:

GitHub Actions Tab Showing the First Running Workflow The first running workflow

When the workflow completes successfully, you'll see a new entry for "Packages" in the right sidebar of your GitHub repository.

The Project's Main Page with a Published Package A published package

Running RuboCop with GitHub Actions

A robust continuous integration pipeline does more than build and publish a Docker image. Oftentimes a Rails CI pipeline uses RuboCop, running the linter on pull requests before allowing a merge.

To add a workflow to run RuboCop as part of our continuous integration in GitHub, we'll create a new file in our repository. While we used the GitHub UI to set up the workflow for Docker, we'll write this one ourselves. Create a new YAML file called .github/workflows/run-linting.yml that looks like this:

name: Linting

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

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2.0'
      - run: bundle install
      - name: RuboCop
        run: rubocop

This workflow is just a slightly modified version of the example in GitHub's documentation for Ruby. It sets up the workflow to run when commits are pushed or when a pull request is open. When the workflow runs, it installs (and caches) Ruby and its dependencies, runs bundle install for Gem dependencies, then runs rubocop.

Commit and push this file to your repository to see it run as an action. Then, go to the Actions tab of your repository, and you'll see your latest run triggered a workflow for both RuboCop and building a Docker image!

GitHub Actions Pipeline Example The Project's Actions History with RuboCop Workflow

Running unit tests with GitHub Actions

Running unit tests or integration tests in GitHub Actions is similar to running RuboCop. You've already created a workflow that installs Ruby, installs your application dependencies, and runs a Ruby gem, which is the exact process we'll use to run tests. We can run RSpec tests with a workflow that installs Ruby, installs our gems, and then runs bundle exec rspec.

Create a new file called .github/workflows/run-tests.yml that looks like this:

name: Tests

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

jobs:
  rspec:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2.0'
      - run: bundle install
      - name: RSpec
        run: bundle exec rspec

The on section for this workflow is the same as the workflow for RuboCop. We run tests on every commit to any branch, and also when a pull request is opened to the main branch.

After you push this file, visiting the Actions tab in your repository will show you the running workflows, with a different entry for Docker, Linting, and RSpec!

You can change the workflow to work with other testing libraries, and even expand it to have database connectivity if your tests require that.

If your tests rely on a Postgres database, for example, you'll need to install Postgres first. Github Actions can still handle setting up your database, but you'll need add some additional configuration to the testing workflow under a services key for your rspec job, install dependencies for Postgres in that job, and set the appropriate environment variables.

Making workflows interact

In many cases, the steps in a CI pipeline will depend on each other. With minor tweaks, we can wait to build the Docker image until the tests pass. There are several ways to achieve this, but one is to use workflow_run as the event trigger for our Docker workflow. Let's change the on key at the top of our docker-publish.yml to look like this:

on:  
  workflow_run:  
    workflows: [Tests]  
    types:  
      - completed

This will only run the Docker workflow if the Test workflow completes. Next, we'll change the build job in this workflow to only run if the completed workflow is successful.

jobs:  
  build:  
    if: ${{ github.event.workflow_run.conclusion == 'success' }}  
    runs-on: ubuntu-latest

Now, when tests fail, the Docker image will not build and publish to a registry. When tests succeed, we'll trigger that workflow to build and publish the image.

Building your own CI pipelines for Rails

In this tutorial, we've explained how to use GitHub Actions to quickly make a CI pipeline. By creating workflows that build Docker images, run tests, and enforce coding standards, we can build confidence that every code change is production-ready and enable continuous deployment. Defining our GitHub Actions CI pipeline as code makes it easy to make and track changes like you would to the application itself.

Hopefully, stepping through this practical GitHub actions pipeline example gives you the confidence to build your own pipeline. Adding CI to your Rails development process empowers your team to ship more reliable software! If you found this helpful, consider signing up for the Honeybadger newsletter to get more articles like this in your inbox.

author photo
Jeffery Morhous

Jeff is a Software Engineer working in healtcare technology using Ruby on Rails, React, and plenty more tools. He loves making things that make life more interesting and learning as much he can on the way. In his spare time, he loves to play guitar, hike, and tinker with cars.

More articles by Jeffery Morhous