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
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:
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.
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!
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.