DEV Community

Cover image for Migrate CircleCI to GitHub Actions: orbs, executors, contexts and test splitting
Kay
Kay

Posted on Originally published at latchkey.dev

Migrate CircleCI to GitHub Actions: orbs, executors, contexts and test splitting

To migrate CircleCI to GitHub Actions, split each entry under workflows: into its own workflow file, turn requires: into needs:, replace orbs with actions, executors and resource_class with a runs-on label, contexts with environment or organization secrets, and persist_to_workspace with the artifact actions. The two things with no direct equivalent are timing-based test splitting and Docker Layer Caching, so plan those before the cutover.

CircleCI config keys mapped to GitHub Actions keys, each marked Maps, Changes or Rewrite
Seventeen CircleCI constructs and where each lands. Test splitting, orbs and Docker Layer Caching are the rows without a mechanical path.

CircleCI and GitHub Actions both keep YAML in the repository with jobs made of steps, so the job bodies translate quickly. The structure around them does not: one config.yml holds every workflow, orbs package jobs and commands you never see, and parallelism with circleci tests run gives you timing-balanced shards from one line of config.

This page maps each construct, converts a config that uses an orb, an executor, a context and four-way test splitting, and gives a resource_class to runner size table so the converted jobs keep the CPU and memory they had.

The concept map

GitHub's guide covers jobs, caching, workspaces and services. The orbs, contexts, resource classes and test splitting rows are where most of the migration time goes.

CircleCI GitHub Actions What changes
workflows: (several in one file) One file per workflow in .github/workflows/ Triggers move into each file's on:
requires: needs: None
filters: branches: on: push: branches: or if: None
orbs: Actions from the Marketplace An orb job or command has to be found or rewritten
commands: Composite action Lives in its own action.yml
executors: with docker: runs-on:, optionally container: CircleCI images set USER circleci, which conflicts with Actions file permissions
Second docker: image services: Named service with its own env:
resource_class: A runs-on: label The label is the size
context: Environment secrets or organization secrets Access rules move from security groups to environments and repository access
parallelism: with circleci tests run strategy: matrix: with your own split No built-in timing-based splitting
restore_cache / save_cache actions/cache with key and restore-keys Immutable keys in both; {{ checksum }} becomes hashFiles()
persist_to_workspace / attach_workspace upload-artifact / download-artifact Named artifacts instead of one workspace
store_test_results No built-in equivalent Upload JUnit XML as an artifact or use an action
Docker Layer Caching No equivalent Pick a build cache separately
type: approval A job on an environment with required reviewers Approval is a property of the environment

From docs.github.com "Migrating from CircleCI to GitHub Actions" and the CircleCI Importer guide, and circleci.com/docs Configuration reference, Using contexts, Caching and Test splitting, read 2026-11-12. GitHub's guide states that timing-based test parallelism "is not built into GitHub Actions" and that Actions has no equivalent of Docker Layer Caching.

Before: a .circleci/config.yml

One workflow, three jobs. The node orb installs packages, a reusable executor sets the image and size, test splits files across four containers by historical timing, and deploy reads a context and only runs on main.

version: 2.1

orbs:
  node: circleci/node@7.2.1

executors:
  app:
    docker:
      - image: cimg/node:22.11
    resource_class: large

jobs:
  build:
    executor: app
    steps:
      - checkout
      - node/install-packages
      - run: npm run build
      - persist_to_workspace:
          root: .
          paths: [dist]
  test:
    executor: app
    parallelism: 4
    environment:
      JEST_JUNIT_OUTPUT_DIR: reports
    steps:
      - checkout
      - node/install-packages
      - run: |
          npx jest --listTests | circleci tests run \
            --command "xargs npx jest --ci --reporters=default --reporters=jest-junit" \
            --split-by=timings
      - store_test_results:
          path: reports
  deploy:
    executor: app
    steps:
      - checkout
      - attach_workspace:
          at: .
      - run: ./scripts/deploy.sh

workflows:
  build-test-deploy:
    jobs:
      - build
      - test:
          requires: [build]
      - deploy:
          requires: [test]
          context: production-deploy
          filters:
            branches:
              only: main
Enter fullscreen mode Exit fullscreen mode

After: the converted workflow

The orb's install-packages command became setup-node with its npm cache plus npm ci. GitHub recommends moving off CircleCI's prebuilt images, so the jobs run on the runner directly instead of in cimg/node. The context became the production environment, whose secrets only the deploy job can read.

The four test containers became a four-cell matrix using Jest's own --shard flag. That splits by test file, not by recorded timing, so shards will be less even than CircleCI's. To get timing balance back, upload each shard's JUnit XML as an artifact and feed past durations into a splitter, as sharding tests across GitHub Actions runners describes.

name: build-test-deploy
on:
  push:
  pull_request:
jobs:
  build:
    runs-on: ubuntu-latest           # was: resource_class: large
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: '22'
          cache: npm
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: dist
          path: dist
  test:
    needs: build
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]             # was: parallelism: 4
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: '22'
          cache: npm
      - run: npm ci
      - run: npx jest --ci --shard=${{ matrix.shard }}/4
  deploy:
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production             # was: context: production-deploy
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: dist
          path: dist
      - run: ./scripts/deploy.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

Resource classes and runner sizes

A resource_class is a size, and on Actions the size is whatever the runs-on label resolves to. Match on vCPU first, then check memory: CircleCI's Docker classes give 2 GB per vCPU, so a job that fit there fits in anything with more. Moving down a size to save money is a separate decision from the migration; make it after the timings are known.

If a job never set resource_class, CircleCI's reference says it ran on a default that can change, so check which class its recent runs actually used before you pick a label.

CircleCI Docker class vCPU and RAM Latchkey label at the same vCPU
medium 2 vCPU, 4 GB latchkey-small: 2 vCPU, 8 GB
large 4 vCPU, 8 GB latchkey-medium: 4 vCPU, 16 GB
xlarge 8 vCPU, 16 GB latchkey-large: 8 vCPU, 32 GB
2xlarge 16 vCPU, 32 GB latchkey-xlarge: 16 vCPU, 64 GB

CircleCI x86 Docker resource classes from the CircleCI Configuration reference, read 2026-11-12. Latchkey sizes from latchkey.dev/documentation/runners-overview, read 2026-11-12. CircleCI also lists small, medium+ and 2xlarge+, which have no same-vCPU label here.

Orbs, test timing and layer caching: the three to plan

Orbs. An orb call is one line in your config and dozens in the orb, so you cannot replace it until you know what it ran. The CircleCI CLI prints the YAML source of any orb version. Read the commands you call, note every step that installs a tool or writes a cache, and find the action or the two lines of shell that do the same. For circleci/node, install-packages is a lockfile install with a cache around it, which setup-node with cache: npm already covers. Orbs that wrap a vendor CLI usually have an official action from the same vendor; check the Marketplace before rewriting one.

Test timing. circleci tests run --split-by=timings balances shards from the JUnit results CircleCI stored for earlier runs. Actions stores nothing between runs unless you do, so the converted workflow starts with file-based shards. Keep the JUnit reporter, and when shard times drift apart, add the timing loop described in parallelizing tests by timing.

Layer caching. CircleCI's Docker Layer Caching was a checkbox on the job. GitHub states there is no equivalent, so a Docker build that relied on it will rebuild every layer on the first runs. Docker layer caching in GitHub Actions compares the BuildKit cache backends you can use instead.

circleci orb source circleci/node@7.2.1 > node-orb.yml
grep -n -A12 'install-packages:' node-orb.yml
Enter fullscreen mode Exit fullscreen mode

The migration checklist

Work through it per workflow in config.yml, plus every orb it imports.

  • Runner labels. Every executor and resource_class becomes a runs-on label from the table above. Self-hosted CircleCI runner classes become self-hosted runners; the Importer marks those executors unsupported.
  • Secrets. Project environment variables and contexts are both manual tasks in GitHub Actions Importer. Recreate context values as environment secrets where approval mattered, organization secrets where it did not.
  • Cache keys. {{ checksum "package-lock.json" }} becomes hashFiles('package-lock.json'). A list of fallback keys becomes key plus restore-keys. Both systems refuse to overwrite an existing key, so the versioning habit (v1-) carries over.
  • Matrix. parallelism becomes a matrix over a shard index; CircleCI matrix parameters become strategy.matrix.
  • Artifacts. persist_to_workspace and store_artifacts both become upload-artifact; give each a name, because there is no single workspace.
  • Concurrency. A serial-group becomes a concurrency: group. Add queue: max so every waiting deploy keeps its place; without it, GitHub keeps one pending run and cancels the one already waiting when a newer one arrives.

CircleCI serial groups queue later jobs first in, first out (CircleCI Docs, controlling serial execution across your organization). GitHub's pending-run rule and queue: max (up to 100 pending) are from Control the concurrency of workflows and jobs. Both read 2026-11-12.

FAQ

What is the GitHub Actions equivalent of CircleCI orbs?

Actions. An orb bundles jobs, commands and executors, and Actions splits those apart: commands become actions, usually from the Marketplace or a composite action you write, and jobs become reusable workflows. GitHub Actions Importer converts known orbs and leaves unknown orbs as manual tasks.

How do I split tests across parallel jobs in GitHub Actions?

Use a matrix over a shard index and give each job its slice, either with the test runner's own option such as Jest's --shard=1/4 or with a script. GitHub states that CircleCI-style timing-based splitting is not built in, so balancing by duration means storing past timings yourself.

What replaces CircleCI contexts in GitHub Actions?

Organization secrets when the point was sharing values across repositories, and environment secrets when the point was restricting who can use them. An environment can require reviewers and restrict which branches deploy, which covers what CircleCI security groups on a context did.

Can I keep using the cimg Docker images on GitHub Actions?

You can run them with container:, but GitHub advises against it: the images set USER circleci, which conflicts with the runner's file permissions on the workspace. GitHub's own converted example needs a chmod step to work around it. Use the runner image and setup actions instead.

References

Top comments (0)