The journey from CircleCI to GitHub Actions

Cover for The journey from CircleCI to GitHub Actions

So, you’re a longtime user of CircleCI with projects set up and running like clockwork. But, one fine morning, it strikes you to switch to GitHub Actions. Why? Maybe you’d like to use fewer third party services and want to keep your code and CI process in one place? Perhaps you already have a paid GitHub account with free GitHub Actions minutes just lying around? One way or another, the decision has been made, and it’s time to figure out what awaits us on the way from CircleCI to GitHub Actions.

In this article, we’ll answer these questions:

  • Is it possible to migrate a Ruby on Rails or a Node.js project from CircleCI to GitHub Actions with (almost) no changes?
  • What are the differences between CircleCI and GitHub Actions, and most importantly, how are they similar?
  • Is it be possible to make the CI process on GitHub Actions faster than on CircleCI?
  • Do we really need containers for CI?
  • How to speed up CI with Docker image build caching?

An overview of GitHub Actions

Before proceeding to the configuration, let’s take a general look at GitHub Actions. As you can see from the overview, it’s very similar to CircleCI. It has the same workflows, which consist of jobs, which in turn are a sequence of one or more steps.

steps can be executed both in the Docker container and directly on the runner’s OS. Each individual step can be either a regular shell command or an action.

Actions are analogous to orbs from CircleCI; they’re used for the same purpose: to combine many operations under one roof and to simplify their reuse. For example, the setup-node action installs the desired version of Node.js, adds it to PATH, and so on.

Most actions are written in JavaScript, but this is not the only way to create them.

You can even use a Docker image as an action! I’ll show an example with this approach at the end of the article.

CircleCI setup

As a test case, I’ll be using a site based on Ruby on Rails and Node.js. Our starting point is the .circleci/config.yml file:

version: 2.1

workflows:
  version: 2
  build-and-deploy:
    jobs:
      - checkout_code
      - bundle_install:
          requires:
            - checkout_code
      - yarn_install:
          requires:
            - checkout_code
      - rubocop:
          requires:
            - bundle_install
      - yarn_build:
          requires:
            - yarn_install
      - test:
          requires:
            - bundle_install
            - yarn_build

executors:
  ruby:
    docker:
      - image: cimg/ruby:3.1.2-node
        environment:
          BUNDLE_PATH: vendor/bundle
          GEM_HOME: vendor/bundle
          BUNDLE_JOBS: 3
          BUNDLE_RETRY: 3
          RAILS_ENV: test

  rails:
    docker:
      - image: cimg/ruby:3.1.2-browsers
        environment:
          BUNDLE_PATH: vendor/bundle
          GEM_HOME: vendor/bundle
          BUNDLE_JOBS: 3
          BUNDLE_RETRY: 3
          RAILS_ENV: test
          INCLUDE_FIXTURES: true
          DATABASE_URL: postgresql://root@localhost:5432/circle_test
          FAIL_SCREEN: 1
      - image: cimg/postgres:14.5
        environment:
          POSTGRES_USER: root
          POSTGRES_DB: circle_test

jobs:
  checkout_code:
    executor: ruby
    steps:
      - attach_workspace:
          at: .
      - checkout
      - persist_to_workspace:
          root: .
          paths: .

  bundle_install:
    executor: ruby
    steps:
      - attach_workspace:
          at: .
      - restore_cache:
          keys:
            - bundle-{{ checksum "Gemfile.lock" }}
      - run:
          name: Configure Bundler
          command: |
            echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV
            source $BASH_ENV
            gem install bundler
      - run:
          name: Bundle Install
          command: bundle check || bundle install
      - save_cache:
          key: bundle-{{ checksum "Gemfile.lock" }}
          paths: vendor/bundle
      - persist_to_workspace:
          root: .
          paths: vendor/bundle

  yarn_install:
    executor: ruby
    steps:
      - attach_workspace:
          at: .
      - restore_node_modules_cache
      - run:
          name: Yarn Install
          command: yarn install --verbose
      - save_node_modules_cache

  yarn_build:
    executor: ruby
    steps:
      - attach_workspace:
          at: .
      - restore_node_modules_cache
      - restore_assets_cache
      - run:
          name: Yarn Build
          command: yarn build --verbose
      - save_assets_cache

  rubocop:
    executor: ruby
    steps:
      - attach_workspace:
          at: .
      - run:
          name: Rubocop
          command: |
            bundle exec rubocop --fail-level E

  test:
    parallelism: 1
    executor: rails
    steps:
      - attach_workspace:
          at: .
      - restore_node_modules_cache
      - restore_assets_cache
      - run:
          name: Wait for database
          command: dockerize -wait tcp://localhost:5432 -timeout 1m
      - run:
          name: Database setup
          command: bundle exec rails db:test:prepare
      - run:
          name: RSpec system tests
          command: |
            BUILT_ASSETS_AVAILABLE=1 bundle exec rspec

commands:
  restore_node_modules_cache:
    description: Restore node_modules/
    steps:
      - restore_cache:
          keys:
            - v1-yarn-dependency-cache-{{ checksum "yarn.lock" }}
            - v1-yarn-dependency-cache

  save_node_modules_cache:
    description: Save node_modules/
    steps:
      - save_cache:
          key: v1-yarn-dependency-cache-{{ checksum "yarn.lock" }}
          paths:
            - node_modules
            - ~/.cache/yarn

  restore_assets_cache:
    description: Restore assets cache
    steps:
      - restore_cache:
          keys:
            - v1-assets-cache-{{ .Branch }}-{{ .Revision }}
            - v1-assets-cache-{{ .Branch }}
            - v1-assets-cache

  save_assets_cache:
    description: Save compiled assets
    steps:
      - save_cache:
          key: v1-assets-cache-{{ .Branch }}-{{ .Revision }}
          paths:
            - public/front

Everything is as usual here: bundle, yarn, rspec and rubocop.

A few important notes on the configuration above:

  • In CircleCI there is such a thing as an executor. There are four types that can be declared and configured: docker, machine, macos, or windows. Later, for each job, it’s sufficient to specify executor: name-of-executor to execute all subsequent steps using it. This is an important note because GitHub Actions does not use this approach. In our CircleCI configuration, one of two executors is used for all steps: ruby and rails. We use the standard Docker images provided and maintained by CircleCI and configure them by passing in environment variables.
  • To avoid doing a checkout at the beginning of each step, we use the checkout_code job, which runs git clone once and stores the contents of the repository in the workspace, which in turn is passed between the individual steps. This speeds up the whole workflow, especially if the repository is very large.
  • The bundler and yarn dependencies, as well as compiled assets, are cached to speed up rebuilds.

So, it’s time to turn your existing CircleCI config into a GitHub Actions config, but how? Let’s go with the easiest way: we’ll try to repeat the same steps with minimal changes and see what happens:

name: Release workflow for Kuvaq

on:
  push:
    branches:
      - main

jobs:
  checkout_code:
    runs-on: ubuntu-24.04

    steps:
    - uses: actions/checkout@v4
    - name: Save repository's content as artifact
      uses: actions/upload-artifact@v4
      with:
        name: repo
        path: ${{ github.workspace }}

  bundle_install:
    runs-on: ubuntu-24.04
    needs: checkout_code

    container: cimg/ruby:3.1.2-node
    env:
      BUNDLE_PATH: vendor/bundle
      GEM_HOME: vendor/bundle
      BUNDLE_JOBS: 3
      BUNDLE_RETRY: 3
      RAILS_ENV: test

    steps:
    - name: Setup file system permissions
      run: sudo chmod -R 777 $GITHUB_WORKSPACE /github /__w/_temp
    - name: Get repository's content
      uses: actions/download-artifact@v4
      with:
        name: repo
    - uses: actions/cache@v4
      with:
        path: vendor/bundle
        key: bundle-${{ hashFiles('**/Gemfile.lock') }}
        restore-keys: |
          bundle-${{ hashFiles('**/Gemfile.lock') }}
          bundle-
    - name: Bundle install
      run: |
        gem install bundler -v "$(cat Gemfile.lock | tail -1 | tr -d " ")"
        bundle install

  yarn_install:
    runs-on: ubuntu-24.04
    needs: checkout_code

    container: cimg/ruby:3.1.2-node
    env:
      BUNDLE_PATH: vendor/bundle
      GEM_HOME: vendor/bundle
      BUNDLE_JOBS: 3
      BUNDLE_RETRY: 3
      RAILS_ENV: test

    steps:
    - name: Setup file system permissions
      run: sudo chmod -R 777 $GITHUB_WORKSPACE /github /__w/_temp
    - name: Get repository's content
      uses: actions/download-artifact@v4
      with:
        name: repo
    - uses: actions/cache@v4
      with:
        path: |
          node_modules
          ~/.cache/yarn
        key: v1-yarn-dependency-cache-${{ hashFiles('**/yarn.lock') }}
        restore-keys: |
          v1-yarn-dependency-cache-${{ hashFiles('**/yarn.lock') }}
          v1-yarn-dependency-cache-
    - name: Yarn Install
      run: yarn install --verbose

  yarn_build:
    runs-on: ubuntu-24.04
    needs: yarn_install

    container: cimg/ruby:3.1.2-node
    env:
      BUNDLE_PATH: vendor/bundle
      GEM_HOME: vendor/bundle
      BUNDLE_JOBS: 3
      BUNDLE_RETRY: 3
      RAILS_ENV: test

    steps:
    - name: Setup file system permissions
      run: sudo chmod -R 777 $GITHUB_WORKSPACE /github /__w/_temp
    - name: Get repository's content
      uses: actions/download-artifact@v4
      with:
        name: repo
    - uses: actions/cache@v4
      with:
        path: |
          node_modules
          ~/.cache/yarn
        key: v1-yarn-dependency-cache-${{ hashFiles('**/yarn.lock') }}
        restore-keys: |
          v1-yarn-dependency-cache-${{ hashFiles('**/yarn.lock') }}
          v1-yarn-dependency-cache-
    - uses: actions/cache@v4
      with:
        path: public/front
        key: v1-assets-cache-${{ github.ref }}-${{ github.sha }}
        restore-keys: |
          v1-assets-cache-${{ github.ref }}-${{ github.sha }}
          v1-assets-cache-${{ github.ref }}
          v1-assets-cache
    - name: Yarn Build
      run: yarn build --verbose

  rubocop:
    needs: bundle_install
    runs-on: ubuntu-24.04

    container: cimg/ruby:3.1.2-node
    env:
      BUNDLE_PATH: vendor/bundle
      GEM_HOME: vendor/bundle
      BUNDLE_JOBS: 3
      BUNDLE_RETRY: 3
      RAILS_ENV: test

    steps:
    - name: Setup file system permissions
      run: sudo chmod -R 777 $GITHUB_WORKSPACE /github /__w/_temp
    - name: Get repository's content
      uses: actions/download-artifact@v4
      with:
        name: repo
    - uses: actions/cache@v4
      with:
        path: vendor/bundle
        key: bundle-${{ hashFiles('**/Gemfile.lock') }}
        restore-keys: |
          bundle-${{ hashFiles('**/Gemfile.lock') }}
          bundle-
    - name: Rubocop
      run: bundle exec rubocop --fail-level E

  test:
    needs:
      - bundle_install
      - yarn_build
    runs-on: ubuntu-24.04

    container: cimg/ruby:3.1.2-browsers
    env:
      BUNDLE_PATH: vendor/bundle
      GEM_HOME: vendor/bundle
      BUNDLE_JOBS: 3
      BUNDLE_RETRY: 3
      RAILS_ENV: test
      INCLUDE_FIXTURES: true
      DATABASE_URL: postgresql://root@postgres:5432/circle_test
      FAIL_SCREEN: 1
      BUILT_ASSETS_AVAILABLE: 1

    services:
      postgres:
        image: cimg/postgres:14.5
        env:
          POSTGRES_USER: root
          POSTGRES_DB: circle_test
        # Add a health check
        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

    steps:
    - name: Setup file system permissions
      run: sudo chmod -R 777 $GITHUB_WORKSPACE /github /__w/_temp
    - name: Get repository's content
      uses: actions/download-artifact@v4
      with:
        name: repo
    - uses: actions/cache@v4
      with:
        path: vendor/bundle
        key: bundle-${{ hashFiles('**/Gemfile.lock') }}
        restore-keys: |
          bundle-${{ hashFiles('**/Gemfile.lock') }}
          bundle-
    - uses: actions/cache@v4
      with:
        path: public/front
        key: v1-assets-cache-${{ github.ref }}-${{ github.sha }}
        restore-keys: |
          v1-assets-cache-${{ github.ref }}-${{ github.sha }}
          v1-assets-cache-${{ github.ref }}
          v1-assets-cache
    - name: Setup database
      run: bundle exec rails db:test:prepare
    - name: RSpec system tests
      run: bundle exec rspec

The end.

I hope you found this article useful, and be sure to keep reading our blog!

…okay, okay, let’s actually see what we did.

CircleCI and GitHub Actions: differences and similarities

We’ll go through our new GitHub Actions configuration and try to suss out what is similar to CircleCI and what is different. Let’s move from top to bottom, and gradually, we’ll deal with everything as we encounter it.

Workflow → Job → Step

In CircleCI, the order in which jobs are executed and the dependencies between them are described in the workflows block. There is no such block in GitHub Actions. Instead, each job has a needs field that accepts a list of other job names that must complete successfully before the current job can begin executing.

Due to the lack of a workflow block in GitHub Actions, the situation is handled differently when you want to run a specific job when you commit to a specific branch.

So, if in CircleCI, you have something like this:

workflows:
  version: 2
  build-and-deploy:
    jobs:
      - . . .
      - bundle_audit:
          requires:
            - bundle_install
          filters:
            branches:
              only: audit
      - . . .

Then, in GitHub Actions, you’ll have to use the if block at the “jobs” level:

on:
  push:
    branches:
      - main
jobs:
  # . . .
  bundle_audit:
    runs-on: ubuntu-24.04
    needs: bundle_install
    if: github.ref == 'refs/heads/audit'
    steps:
      # . . .
    # . . .
 # . . .

persist_to_workspace vs. upload-artifact

Let’s see how the checkout_code job has changed.

The closest equivalent for the persist_to_workspace and attach_workspace pair from the GitHub Actions world are the upload-artifact and download-artifact actions.

Note that the GitHub Action does not use a special checkout step to download code from the repository. Instead, the checkout action is responsible for getting the code, which is no different from other actions you can find in the marketplace.

The container and env fields

Now let’s take a look at the bundle_install job.

As I said before, in GitHub Actions each step can be a shell command or an action. An action itself can be a JavaScript program or a Docker container. Both shell commands and actions of all kinds are executed directly on the runner (if the job description does not have the container field). If present, all steps are executed inside the container that has been created from the specified image.

jobs:
  job_name_here:
    steps:
    # shell command
    - name: This step will run by shell on runner OS
      run: echo "Hotel Menetekel"
    # JavaScript action
    - name: This step will run JavaScript code on runner OS
      uses: actions/hello-world-javascript-action@main
    # Docker container action
    - name: This step will run separate container
      uses: actions/hello-world-docker-action@main
jobs:
  job_name_here:
    container: ruby:3.1
    steps:
    # shell command
    - name: This step will run by shell inside ruby container
      run: echo "Hotel Menetekel"
    # JavaScript action
    - name: This step will run JavaScript code inside ruby container
      uses: actions/hello-world-javascript-action@main
    # Docker container action
    - name: This step will still run separate container, yes
      uses: uses: actions/hello-world-docker-action@main

An action can be a Docker image and run as Docker container. The question immediately arises: what will happen to actions of that kind? Will it be docker-in-docker? As you can see from above example, no. Actually the container actions will be run as sibling containers on the same network with the same volume mounts.

The env block specifies the environment variables that will be passed to each step. These variables are the same for all jobs except for checkout_code and test. It would be convenient to use YAML anchors to avoid repetition, but unfortunately, they’re still not supported in Github Actions.

Now, let’s take an individual look at some of the steps.

The CircleCI Docker images nuisance

- name: Setup file system permissions
  run: sudo chmod -R 777 $GITHUB_WORKSPACE /github /__w/_temp

This step gives full access to the $GITHUB_WORKSPACE, /github and /__w/_temp directories for any user. This is a side effect of using CircleCI images. Their Dockerfiles use the USER circleci statement, so the user in the container does not have sufficient rights to access these directories.

To exclude this step, you need to use Docker images without the USER statement. We use these images because we want to replicate our CircleCI config as the first step to our new GitHub Actions config.

Caching in GitHub Actions

- uses: actions/cache@v4
  with:
    path: vendor/bundle
    key: bundle-${{ hashFiles('**/Gemfile.lock') }}
    restore-keys: |
      bundle-${{ hashFiles('**/Gemfile.lock') }}
      bundle-

As with checkout, caching in GitHub Actions is performed by an action, not by the reserved save_cache and restore_cache. actions/cache@v4 performs the save and restore in one step. If a cache with the required key is found, then it is connected and used in the current step. If a cache is not found, it will be created and saved in case of successful completion of the job (not the current step, but the entire job).

Just like in CircleCI, you can specify a list of keys by which the cache can be retrieved if no exact match is found.

services Block

Well, all interesting parts of the bundle_install job have now been examined. It makes no sense to focus on the yarn_install, yarn_build and rubocop jobs, because they’re almost the same as bundle_install.

Let’s move on to the test job, which is slightly different from what we’ve seen before:

test:
  # . . .
  container: cimg/ruby:3.1.2-browsers
  env:
    # . . .
    DATABASE_URL: postgresql://root@postgres:5432/circle_test

  services:
    postgres:
      image: cimg/postgres:14.5
      env:
        POSTGRES_USER: root
        POSTGRES_DB: circle_test
      # Add a health check
      options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

In CircleCI, additional containers within the same job can be listed in an executor declaration. All the steps described in the job are performed in the first of the listed containers and the remaining containers are connected to the same network and are available through localhost.

GitHub Actions uses the container and services fields for this purpose. The first specifies the main container and the second lists additional containers that will be needed during the execution of the job. For example, in our case it is a database.

Note that the value of the DATABASE_URL variable in the env block has changed from postgresql://root@localhost:5432/circle_test to postgresql://root@postgres:5432/circle_test. Unlike CircleCI, in GitHub Actions, additional containers are not available through localhost, but by their names, which are specified in the services block when they are created.

Testing the new Github Action configuration

Well, we’ve finished discussing the configuration for GitHub Actions! Let’s check what came out of it. We’ll save the contents to the file asis.yml and put it in the root of the repository along the path .github/workflows/asis.yml. Next, we’ll invoke the most famous spell from The Standard Book of Spells, which goes something like this: git add - git commit - git push. We then go to the Actions tab in the web interface of the GitHub repository.

The Actions tab shows the results of our first attempt

Not the best result! The entire workflow takes as much as 10 minutes, whereas on CircleCI, it only takes about 4. Let’s try to figure out the stage where we are losing so much time. We’ll take the job bundle_install as an example.

A view of different jobs and the time to finish them.

You can notice (and this is also visible in all other jobs) that the Get repository's content step, during which the artifact with the contents of the repository is downloaded, takes the longest time. Apparently, the upload-artifact - download-artifact action pair is not a good replacement for the persist_to_workspace - attach_workspace pair from CircleCI.

Let’s try to replace this step in all jobs with actions/checkout@v4. If you think about it, checkout in GitHub Actions should be faster than its CircleCI counterpart checkout_code because both the runner and the server that hosts the repository are likely to be close to each other from a network point of view, and it’s likely that the throughput between them will be very high.

Let’s test this theory and see the result.

Much better results in the Actions tab of the GitHub interface

Well, this is a good result!

We got rid of the checkout_code job, and we added a first step which downloads the contents of the repository using actions/checkout@v4 to each job, thus reducing the time to 5 minutes.

Can we go even further and speed up the whole process even more?

No more containers

Let’s take a look at the execution statistics for each individual job again.

Execution statistics for each individual job.

Have you noticed which step is repeated in all jobs and takes much more time than the rest? Initialize containers takes 30-50% of the entire job execution time! Most of that time is spent downloading huge Docker images on runner. For example, the compressed image cimg/ruby:3.1.2-node is 592.69 MB and needs to be downloaded to complete almost every job in our workflow.

A natural question emerges here: is it really necessary to perform all actions inside containers? Is there another way to get an almost equally repeatable environment, access to the right utilities at the execution stage of each individual step, while at the same time reducing the overall preparation time?

There is such a way, and I think it will come as no surprise that all of this can be done using actions!

There are many actions, like setup-name-of-language-here, suitable for every taste: Ruby, Java, Go, Node.js, Python and so on.

All of them perform the same function: preparing the environment for the corresponding language. Instead of downloading a huge Ruby container, you can use the action ruby/setup-ruby@v1 and it will install the desired version in seconds, add it to $PATH, run bundle install and set up gem caching!

We only need a few lines of configuration:

- uses: ruby/setup-ruby@v1
  with:
    ruby-version: 3.1.2
    bundler-cache: true

Similarly, we add everything necessary for Node.js:

- uses: actions/setup-node@v4
  with:
    node-version: '16'
    cache: 'yarn'

After replacing all containers with the corresponding actions, the resulting workflow looks like this:

---
name: Release workflow for Kuvaq

on:
  push:
    branches:
      - main

jobs:
  bundle_install:
    runs-on: ubuntu-24.04

    env:
      BUNDLE_PATH: vendor/bundle
      GEM_HOME: vendor/bundle
      BUNDLE_JOBS: 3
      BUNDLE_RETRY: 3
      RAILS_ENV: test

    steps:
    - uses: actions/checkout@v4
    - uses: ruby/setup-ruby@v1
      with:
        ruby-version: 3.1.2
        bundler-cache: true

  yarn_install:
    runs-on: ubuntu-24.04

    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: '16'
        cache: 'yarn'
    - name: Yarn Install
      run: yarn install --verbose

  yarn_build:
    runs-on: ubuntu-24.04
    needs: yarn_install

    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: '16'
        cache: 'yarn'
    - uses: actions/cache@v4
      with:
        path: public/front
        key: v1-assets-cache-${{ github.ref }}-${{ github.sha }}
        restore-keys: |
          v1-assets-cache-${{ github.ref }}-${{ github.sha }}
          v1-assets-cache-${{ github.ref }}
          v1-assets-cache
    - name: Yarn Build
      run: yarn build --verbose

  rubocop:
    needs: bundle_install
    runs-on: ubuntu-24.04

    env:
      BUNDLE_PATH: vendor/bundle
      GEM_HOME: vendor/bundle
      BUNDLE_JOBS: 3
      BUNDLE_RETRY: 3
      RAILS_ENV: test

    steps:
    - uses: actions/checkout@v4
    - uses: ruby/setup-ruby@v1
      with:
        ruby-version: 3.1.2
        bundler-cache: true
    - name: Rubocop
      run: bundle exec rubocop --fail-level E

  test:
    needs:
      - bundle_install
      - yarn_build
    runs-on: ubuntu-24.04

    env:
      BUNDLE_PATH: vendor/bundle
      GEM_HOME: vendor/bundle
      BUNDLE_JOBS: 3
      BUNDLE_RETRY: 3
      RAILS_ENV: test
      INCLUDE_FIXTURES: true
      DATABASE_URL: postgresql://root:postgres@localhost:5432/circle_test
      FAIL_SCREEN: 1
      BUILT_ASSETS_AVAILABLE: 1

    services:
      postgres:
        image: postgres:14.5
        env:
          POSTGRES_DB: circle_test
          POSTGRES_USER: root
          POSTGRES_PASSWORD: postgres
        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
        ports:
          - 5432:5432

    steps:
    - uses: actions/checkout@v4
    - uses: ruby/setup-ruby@v1
      with:
        ruby-version: 3.1.2
        bundler-cache: true
    # we need this step for restoring of cache
    - uses: actions/setup-node@v4
      with:
        node-version: '16'
        cache: 'yarn'
    - uses: actions/cache@v4
      with:
        path: public/front
        key: v1-assets-cache-${{ github.ref }}-${{ github.sha }}
        restore-keys: |
          v1-assets-cache-${{ github.ref }}-${{ github.sha }}
          v1-assets-cache-${{ github.ref }}
          v1-assets-cache
    - name: Setup database
      run: bundle exec rails db:test:prepare
    - name: RSpec system tests
      run: bundle exec rspec

With that, let’s see if we managed to reduce the time of the entire workflow:

Now, this is a really good result! We’ve not only managed to reach the CircleCI time, but we’ve also significantly surpassed it!

Of course, this result is a consequence of the compromise we made when we abandoned containers. On one hand, containers allow you to achieve close to 100% environment repeatability, but at the same time, they greatly slow the entire CI process.

Sure, using actions doesn’t guarantee complete repeatability (you have no control over the state of the runner’s operating system and the packages installed there), but in my opinion, there are many situations where this level of repeatability is enough.

Take a close look at your CircleCI configuration when you transfer it to GitHub Actions—perhaps many steps do not require containers and can be replaced by existing actions. Doing this, as we saw from our example, can significantly reduce the total time of the entire workflow. And this means speeding up the development process and reducing your monthly bill for GitHub Actions!

Bonus: Docker layer caching

As a bonus for those who have read this article to the end, I offer a small addition. It’s highly likely that at the end of the workflow, you’ll want to build a Docker image with your application. It’s even more likely that you’ll want to do this on every commit, and most importantly, as quickly as possible.

A year ago, our blog already had an article about caching when building docker images in GitHub Actions, but since then, everything has become much easier.

Let’s take a look at a job that builds and pushes the image, and then we’ll deal with the most interesting parts:

  build-and-push:
    runs-on: ubuntu-24.04
    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Login to GAR
        uses: docker/login-action@v3
        with:
          registry: us-docker.pkg.dev
          username: _json_key
          password: ${{ secrets.GA_GCP_JSON_KEY }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: ./
          file: ./Dockerfile.k8s
          build-args: |
            RUBY_VERSION=3.1.2
            PG_MAJOR=14
            NODE_MAJOR=16
            BUNDLER_VERSION=2.3.21
            RAILS_ENV=development
            NODE_ENV=development
          push: true
          tags: us-docker.pkg.dev/deponia/portafisco/kuvaq:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Save Google credentials to file
        run: echo "${GOOGLE_APPLICATION_CREDENTIALS}" > "${GITHUB_WORKSPACE}/ga_gcp_json_key.json"
        env:
          GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GA_GCP_JSON_KEY }}

      - name: Clean Google Artifact Registry
        uses: docker://us-docker.pkg.dev/gcr-cleaner/gcr-cleaner/gcr-cleaner-cli@sha256:959419dd9402edd27f4116d95bb21a9ed6024adec8ea777588a2031a80b91df2
        with:
          args: >
            -repo=us-docker.pkg.dev/deponia/portafisco/kuvaq
            -grace=2160h
            -keep=30
            -allow-tagged=true
            -dry-run=false
        env:
          GOOGLE_APPLICATION_CREDENTIALS: ${{ github.workspace }}/ga_gcp_json_key.json

As you can see in the Login to GAR step, in our case, we’re using the Google Artifact Registry as a place to store our images, but many other repositories are also supported.

The next step is preparing the Docker Buildx, but the most interesting part happens in the Build and push step.

Previously, caching required type=local and a separate step to save and restore the cache from the temporary directory, today however, a simple type=gha in the cache-from and cache-to fields is enough!

Although this type of cache is still in the experimental stage, it works great and has never caused problems. If you want to get more confidence with this, you can find the type of cache that suits your needs, as well as other options here.

Pay attention to the last step. Here, the uses field does not specify an action, but a usual Docker image, to which arguments and environment variables are passed. This entire step is a big replacement for the docker run ... command. It uses the gcr-cleaner program, which removes old images that do not meet the conditions listed in the args field. This is a very handy way of controlling the size of a docker repository and keeping it within reasonable limits.

Well, that just about wraps things up. The journey from CircleCI to GitHub Actions was quite a ride, but hopefully you found it to be relatively turbulence free! And one more thing: if you have a problem or project in need: frontend, backend, SRE services, mobile, blockchain, or ML—Evil Martians are ready to help! Give us a shout!

Join our email newsletter

Get all the new posts delivered directly to your inbox. Unsubscribe anytime.